diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d9bdd84..e3329b1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: quality: name: Format, Lint, Typecheck, Test, Browser Test, Build - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -76,7 +76,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 028326dd..25b4ac21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: tags: - "v*.*.*" schedule: - - cron: "0 9 * * *" + - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: @@ -26,9 +26,46 @@ permissions: id-token: write jobs: + check_changes: + name: Check for changes since last nightly + if: github.event_name == 'schedule' + runs-on: ubuntu-24.04 + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: check + name: Compare HEAD to last nightly tag + run: | + last_nightly_tag=$(git tag --list 'nightly-v*' --sort=-creatordate | head -n 1) + if [[ -z "$last_nightly_tag" ]]; then + echo "No previous nightly tag found. Proceeding with release." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") + head_sha=$(git rev-parse HEAD) + + if [[ "$last_nightly_sha" == "$head_sha" ]]; then + echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + preflight: name: Preflight - runs-on: ubuntu-24.04 + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -37,6 +74,7 @@ jobs: release_name: ${{ steps.release_meta.outputs.name }} short_sha: ${{ steps.release_meta.outputs.short_sha }} previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} ref: ${{ github.sha }} @@ -79,6 +117,7 @@ jobs: --github-output echo "release_channel=nightly" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -102,6 +141,7 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "is_prerelease=false" >> "$GITHUB_OUTPUT" echo "make_latest=true" >> "$GITHUB_OUTPUT" @@ -131,6 +171,7 @@ jobs: build: name: Build ${{ matrix.label }} needs: preflight + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 strategy: @@ -138,22 +179,22 @@ jobs: matrix: include: - label: macOS arm64 - runner: macos-26 + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-26-intel + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: ubuntu-24.04 + runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 # blacksmith-32vcpu-windows-2025 + runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 @@ -185,6 +226,23 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Install Spectre-mitigated MSVC libs + if: matrix.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + - name: Build desktop artifact shell: bash env: @@ -293,10 +351,11 @@ jobs: path: release-publish/* if-no-files-found: error - release: - name: Publish GitHub Release + publish_cli: + name: Publish CLI to npm needs: [preflight, build] - runs-on: ubuntu-24.04 + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -304,18 +363,61 @@ jobs: with: ref: ${{ needs.preflight.outputs.ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Setup Node uses: actions/setup-node@v6 with: node-version-file: package.json + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Build CLI package + run: bun run build --filter=@t3tools/web --filter=t3 + + - name: Publish CLI package + run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose + + release: + name: Publish GitHub Release + needs: [preflight, build, publish_cli] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version-file: package.json + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts + run: bun install --frozen-lockfile - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -383,6 +485,7 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' @@ -402,12 +505,13 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true + token: ${{ steps.app_token.outputs.token }} finalize: name: Finalize release - if: needs.preflight.outputs.release_channel == 'stable' + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5fbd3021..2a4ced70 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,6 +23,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts new file mode 100644 index 00000000..6d1df3d3 --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { BackendReadinessAbortedError } from "./backendReadiness.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; + +describe("waitForBackendStartupReady", () => { + it("falls back to the HTTP probe when no listening signal exists", async () => { + const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); + const cancelHttpWait = vi.fn(); + + await expect( + waitForBackendStartupReady({ + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("http"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).not.toHaveBeenCalled(); + }); + + it("uses the listening signal and cancels the HTTP probe", async () => { + let rejectHttpWait: ((error: unknown) => void) | null = null; + const waitForHttpReady = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectHttpWait = reject; + }), + ); + const cancelHttpWait = vi.fn(() => { + rejectHttpWait?.(new BackendReadinessAbortedError()); + }); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.resolve(), + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("listening"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).toHaveBeenCalledTimes(1); + }); + + it("rejects when the listening signal fails before HTTP readiness", async () => { + const error = new Error("backend exited"); + const waitForHttpReady = vi.fn(() => new Promise(() => {})); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.reject(error), + waitForHttpReady, + cancelHttpWait: vi.fn(), + }), + ).rejects.toBe(error); + }); +}); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts new file mode 100644 index 00000000..37a97743 --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.ts @@ -0,0 +1,56 @@ +import { isBackendReadinessAborted } from "./backendReadiness.ts"; + +export interface WaitForBackendStartupReadyOptions { + readonly listeningPromise?: Promise | null; + readonly waitForHttpReady: () => Promise; + readonly cancelHttpWait: () => void; +} + +export async function waitForBackendStartupReady( + options: WaitForBackendStartupReadyOptions, +): Promise<"listening" | "http"> { + const httpReadyPromise = options.waitForHttpReady(); + const listeningPromise = options.listeningPromise; + + if (!listeningPromise) { + await httpReadyPromise; + return "http"; + } + + return await new Promise<"listening" | "http">((resolve, reject) => { + let settled = false; + + const settleResolve = (source: "listening" | "http") => { + if (settled) { + return; + } + settled = true; + if (source === "listening") { + options.cancelHttpWait(); + } + resolve(source); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + listeningPromise.then( + () => settleResolve("listening"), + (error) => settleReject(error), + ); + httpReadyPromise.then( + () => settleResolve("http"), + (error) => { + if (settled && isBackendReadinessAborted(error)) { + return; + } + settleReject(error); + }, + ); + }); +} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 5489bb89..7f37308f 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -108,7 +108,7 @@ describe("desktopSettings", () => { }); }); - it("preserves a legacy saved stable channel on nightly builds", () => { + it("preserves legacy implicit stable settings on nightly builds", () => { const settingsPath = makeSettingsPath(); fs.writeFileSync( settingsPath, @@ -144,40 +144,4 @@ describe("desktopSettings", () => { updateChannelConfiguredByUser: true, }); }); - - it("preserves a legacy explicit stable choice on nightly builds", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - serverExposureMode: "local-only", - updateChannel: "latest", - }), - "utf8", - ); - - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); - - it("does not treat legacy nightly settings as an explicit track override", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - serverExposureMode: "local-only", - updateChannel: "nightly", - }), - "utf8", - ); - - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ - serverExposureMode: "local-only", - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ef80f5c..529ed55d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopServerExposure } from "./serverExposure.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; import { ServerListeningDetector } from "./serverListeningDetector.ts"; @@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - const httpReadyPromise = waitForBackendHttpReady(baseUrl, { - timeoutMs: 60_000, - }); - const listeningPromise = backendListeningDetector?.promise; - - if (!listeningPromise) { - await httpReadyPromise; - return "http"; - } - - return await new Promise<"listening" | "http">((resolve, reject) => { - let settled = false; - - const settleResolve = (source: "listening" | "http") => { - if (settled) { - return; - } - settled = true; - if (source === "listening") { - cancelBackendReadinessWait(); - } - resolve(source); - }; - - const settleReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - reject(error); - }; - - listeningPromise.then( - () => settleResolve("listening"), - (error) => settleReject(error), - ); - httpReadyPromise.then( - () => settleResolve("http"), - (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } - settleReject(error); - }, - ); + return await waitForBackendStartupReady({ + listeningPromise: backendListeningDetector?.promise ?? null, + waitForHttpReady: () => + waitForBackendHttpReady(baseUrl, { + timeoutMs: 60_000, + }), + cancelHttpWait: cancelBackendReadinessWait, }); } @@ -2119,9 +2082,9 @@ async function bootstrap(): Promise { if (isDevelopment) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendHttpReady(backendHttpUrl) - .then(() => { - writeDesktopLogHeader("bootstrap backend ready"); + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); }) .catch((error) => { if (isBackendReadinessAborted(error)) { diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 53b00393..74067b12 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/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6daa43ca..6f9f4c6f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -58,6 +58,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; +import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -351,6 +352,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/package.json b/apps/server/package.json index 2843d207..d2830507 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@github/copilot-sdk": "^0.2.2", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -43,6 +44,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts new file mode 100644 index 00000000..9f01ca1e --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.ts @@ -0,0 +1,608 @@ +#!/usr/bin/env bun +import { appendFileSync } from "node:fs"; + +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 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"; +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"; +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 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 = [ + { + 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", + name: "Model", + category: "model", + type: "select" as const, + 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: ReadonlyArray = [ + { + 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", + }, +]; + +function modeState(): AcpSchema.SessionModeState { + return { + currentModeId, + availableModes, + }; +} + +const program = Effect.gen(function* () { + const agent = yield* EffectAcpAgent.AcpAgent; + + yield* agent.handleInitialize((request) => + Effect.sync(() => { + parameterizedModelPicker = + request.clientCapabilities?._meta?.parameterizedModelPicker === true; + return { + 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" }, + }, + }) + .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 === "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(), + }; + }), + ); + + yield* agent.handleSetSessionMode((request) => + Effect.gen(function* () { + const nextModeId = request.modeId.trim(); + if (!nextModeId) { + return {}; + } + currentModeId = nextModeId; + yield* agent.client.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId, + }, + }); + return {}; + }), + ); + + yield* agent.handleCancel(({ sessionId }) => + Effect.sync(() => { + cancelledSessions.add(String(sessionId ?? "mock-session-1")); + }), + ); + + yield* agent.handlePrompt((request) => + 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"; + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + 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", + }, + }); + + const permission = yield* agent.client.requestPermission({ + 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" }, + ], + }); + + 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 (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", + title: "Question", + questions: [ + { + id: "scope", + prompt: "Which scope?", + options: [ + { id: "workspace", label: "Workspace" }, + { id: "session", label: "Session" }, + ], + }, + ], + }); + + 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", + }, + ], + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: promptResponseText ?? "hello from mock" }, + }, + }); + + return { stopReason: "end_turn" }; + }), + ); + + yield* agent.handleUnknownExtRequest((method, params) => { + if (method !== "session/mode/set") { + return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); + } + + const nextModeId = + 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(); + return agent.client + .sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "current_mode_update", + currentModeId, + }, + }) + .pipe(Effect.as({})); + } + + 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/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts new file mode 100644 index 00000000..f3152ab1 --- /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 | null | undefined; + + 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 | null | undefined; + 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/bin.ts b/apps/server/src/bin.ts index 4ae638db..874898d8 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -10,9 +10,8 @@ import packageJson from "../package.json" with { type: "json" }; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -NodeRuntime.runMain( - Command.run(cli, { version: packageJson.version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - ), +Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(CliRuntimeLayer), + NodeRuntime.runMain, ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 184ec963..211877e9 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -19,6 +19,8 @@ import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; +const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; + const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -245,6 +247,7 @@ const makeCheckpointStore = Effect.gen(function* () { operation, cwd: input.cwd, args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, }); return result.stdout; diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index acdf656e..7ebde010 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -37,7 +37,8 @@ import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); -const runCliWithRuntime = (args: ReadonlyArray) => runCli(args); +const runCliWithRuntime = (args: ReadonlyArray) => + runCli(args).pipe(Effect.provide(CliRuntimeLayer)); const captureStdout = (effect: Effect.Effect) => Effect.gen(function* () { @@ -146,7 +147,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ); }); -it.layer(CliRuntimeLayer)("cli log-level parsing", (it) => { +it.layer(NodeServices.layer)("cli log-level parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => runCliWithRuntime(["--log-level", "debug", "--version"]), ); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index c644b108..937f7a1b 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -471,7 +471,7 @@ describe("startSession", () => { } }); - it("keeps the existing session alive when replacement startup fails before initialization", async () => { + it("disposes an existing session before starting a replacement for the same thread", async () => { const manager = new CodexAppServerManager(); const existingContext = { session: { @@ -527,15 +527,10 @@ describe("startSession", () => { }), ).rejects.toThrow("cwd missing"); - expect(disposeSession).not.toHaveBeenCalled(); + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); - expect( - ( - manager as unknown as { - sessions: Map; - } - ).sessions.get(asThreadId("thread-1")), - ).toBe(existingContext); } finally { disposeSession.mockRestore(); assertSupportedCodexCliVersion.mockRestore(); @@ -549,7 +544,7 @@ describe("startSession", () => { } }); - it("keeps the existing session mapped when replacement startup fails even if disposal would fail", async () => { + it("continues replacement start when existing session disposal fails", async () => { const manager = new CodexAppServerManager(); const existingContext = { session: { @@ -607,15 +602,10 @@ describe("startSession", () => { }), ).rejects.toThrow("cwd missing"); - expect(disposeSession).not.toHaveBeenCalled(); + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); - expect( - ( - manager as unknown as { - sessions: Map; - } - ).sessions.get(asThreadId("thread-1")), - ).toBe(existingContext); } finally { disposeSession.mockRestore(); assertSupportedCodexCliVersion.mockRestore(); @@ -629,145 +619,48 @@ describe("startSession", () => { } }); - it("stops both the active and in-flight replacement sessions for the thread", () => { + it("disposes an existing pending session before starting a replacement for the same thread", async () => { const manager = new CodexAppServerManager(); - const threadId = asThreadId("thread-1"); - const existingContext = { + const existingPendingContext = { session: { provider: "codex", - status: "ready", - threadId, + status: "connecting", + threadId: asThreadId("thread-1"), runtimeMode: "full-access", createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - stopping: false, - output: { - close: vi.fn(), - }, - child: { - killed: true, - }, }; - ( - manager as unknown as { - sessions: Map; - } - ).sessions.set(threadId, existingContext); - const pendingContext = { - session: { - provider: "codex", - status: "connecting", - threadId, - runtimeMode: "full-access", - createdAt: "2026-02-10T00:00:01.000Z", - updatedAt: "2026-02-10T00:00:01.000Z", - }, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - stopping: false, - output: { - close: vi.fn(), - }, - child: { - killed: true, - }, - }; ( manager as unknown as { - pendingSessions: Map; + pendingSessions: Map; } - ).pendingSessions.set(threadId, pendingContext); - - const disposeSession = vi.spyOn( - manager as unknown as { - disposeSession: ( - context: unknown, - options?: { readonly emitLifecycleEvent?: boolean }, - ) => void; - }, - "disposeSession", - ); + ).pendingSessions.set(asThreadId("thread-1"), existingPendingContext); - try { - manager.stopSession(threadId); - - expect(disposeSession).toHaveBeenCalledTimes(2); - expect(disposeSession.mock.calls[0]?.[0]).toBe(pendingContext); - expect(disposeSession.mock.calls[1]?.[0]).toBe(existingContext); - expect( - ( - manager as unknown as { - sessions: Map; - } - ).sessions.has(threadId), - ).toBe(false); - expect( - ( - manager as unknown as { - pendingSessions: Map; - } - ).pendingSessions.has(threadId), - ).toBe(false); - } finally { - disposeSession.mockRestore(); - ( + const disposeSession = vi + .spyOn( manager as unknown as { - sessions: Map; - pendingSessions: Map; - } - ).sessions.clear(); - ( + disposeSession: ( + context: typeof existingPendingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => {}); + const assertSupportedCodexCliVersion = vi + .spyOn( manager as unknown as { - pendingSessions: Map; - } - ).pendingSessions.clear(); - manager.stopAll(); - } - }); - - it("replaces an in-flight pending startup before beginning a new session", async () => { - const manager = new CodexAppServerManager(); - const threadId = asThreadId("thread-pending-replacement"); - const pendingContext = { - session: { - provider: "codex", - status: "connecting", - threadId, - runtimeMode: "full-access", - createdAt: "2026-02-10T00:00:01.000Z", - updatedAt: "2026-02-10T00:00:01.000Z", - }, - pending: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - collabReceiverTurns: new Map(), - stopping: false, - output: { close: vi.fn() }, - child: { killed: true }, - }; - ( - manager as unknown as { - pendingSessions: Map; - } - ).pendingSessions.set(threadId, pendingContext); - - const disposeSession = vi.spyOn( - manager as unknown as { - disposeSession: ( - context: unknown, - options?: { readonly emitLifecycleEvent?: boolean }, - ) => void; - }, - "disposeSession", - ); + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { throw new Error("cwd missing"); }); @@ -775,152 +668,78 @@ describe("startSession", () => { try { await expect( manager.startSession({ - threadId, + threadId: asThreadId("thread-1"), provider: "codex", binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); - expect(disposeSession).toHaveBeenCalledWith( - pendingContext, - expect.objectContaining({ emitLifecycleEvent: false }), - ); - expect( - ( - manager as unknown as { - pendingSessions: Map; - } - ).pendingSessions.has(threadId), - ).toBe(false); + expect(disposeSession).toHaveBeenCalledWith(existingPendingContext, { + emitLifecycleEvent: false, + }); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); } finally { disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); processCwd.mockRestore(); + ( + manager as unknown as { + pendingSessions: Map; + } + ).pendingSessions.clear(); manager.stopAll(); } }); - it("removes pending startup sessions when the child exits before ready", () => { + it("removes a connecting pending session from the pending map when stopped", () => { const manager = new CodexAppServerManager(); - const threadId = asThreadId("thread-exit-pending"); - const exitHandlers: Array<(code: number | null, signal: NodeJS.Signals | null) => void> = []; - type PendingExitTestContext = { + const outputClose = vi.fn(); + const child = { + killed: true, + } as unknown as NodeJS.Process & import("node:child_process").ChildProcessWithoutNullStreams; + const pendingContext = { session: { - provider: "codex"; - status: "connecting"; - threadId: ThreadId; - runtimeMode: "full-access"; - createdAt: string; - updatedAt: string; - }; - pending: Map; - pendingApprovals: Map; - pendingUserInputs: Map; - collabReceiverTurns: Map; - stopping: boolean; + provider: "codex" as const, + status: "connecting" as const, + threadId: asThreadId("thread-connecting"), + runtimeMode: "full-access" as const, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + account: { + type: "unknown" as const, + planType: null, + sparkEnabled: true, + }, + child, output: { - on: ReturnType; - close: ReturnType; - }; - child: { - stderr: { - on: ReturnType; - }; - on: ReturnType; - killed: boolean; - }; - }; - - const context: PendingExitTestContext = { - session: { - provider: "codex", - status: "connecting", - threadId, - runtimeMode: "full-access", - createdAt: "2026-02-10T00:00:01.000Z", - updatedAt: "2026-02-10T00:00:01.000Z", + close: outputClose, }, pending: new Map(), pendingApprovals: new Map(), pendingUserInputs: new Map(), collabReceiverTurns: new Map(), + nextRequestId: 1, stopping: false, - output: { - on: vi.fn(), - close: vi.fn(), - }, - child: { - stderr: { - on: vi.fn(), - }, - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - if (event === "exit") { - exitHandlers.push( - handler as (code: number | null, signal: NodeJS.Signals | null) => void, - ); - } - }), - killed: false, - }, }; ( manager as unknown as { - pendingSessions: Map; - } - ).pendingSessions.set(threadId, context); - - ( - manager as unknown as { - attachProcessListeners: (context: PendingExitTestContext) => void; + pendingSessions: Map; } - ).attachProcessListeners(context); - - expect(exitHandlers).toHaveLength(1); + ).pendingSessions.set(asThreadId("thread-connecting"), pendingContext); - exitHandlers[0]!(1, null); + manager.stopSession(asThreadId("thread-connecting")); expect( ( manager as unknown as { - pendingSessions: Map; + pendingSessions: Map; } - ).pendingSessions.has(threadId), + ).pendingSessions.has(asThreadId("thread-connecting")), ).toBe(false); - }); - - it("does not treat pending startup sessions as active via hasSession", () => { - const manager = new CodexAppServerManager(); - const threadId = asThreadId("thread-pending-only"); - - ( - manager as unknown as { - pendingSessions: Map< - ThreadId, - { - session: { - provider: "codex"; - status: "connecting"; - threadId: ThreadId; - runtimeMode: "full-access"; - createdAt: string; - updatedAt: string; - }; - } - >; - } - ).pendingSessions.set(threadId, { - session: { - provider: "codex", - status: "connecting", - threadId, - runtimeMode: "full-access", - createdAt: "2026-02-10T00:00:01.000Z", - updatedAt: "2026-02-10T00:00:01.000Z", - }, - }); - - expect(manager.hasSession(threadId)).toBe(false); + expect(outputClose).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 4f1b8a7f..425ee169 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -449,25 +449,43 @@ export class CodexAppServerManager extends EventEmitter): 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 withFakeAcpAgent( + env: Record, + effect: Effect.Effect, +): 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 yield* effect.pipe( + Effect.ensuring( + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousSettings.providers.cursor.binaryPath, + }, + }, + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ), + Effect.asVoid, + ), + ), + ); + }); +} + +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-")); + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpAgent( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add generated commit message", + body: "- verify cursor acp model config path", + }), + }, + 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: "gpt-5.4", + options: { + reasoning: "xhigh", + fastMode: true, + contextWindow: "1m", + }, + }, + }); + + expect(generated.subject).toBe("Add generated commit message"); + 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 === "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 assistant text around them", () => + withFakeAcpAgent( + { + 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.', + }, + 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(""); + }), + ), + ); + + it.effect("generates thread titles through Cursor ACP text generation", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: '"Trim reconnect spinner status after resume."', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + provider: "cursor", + model: "composer-2", + }, + }); + + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }), + ), + ); + + 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 new file mode 100644 index 00000000..754f3737 --- /dev/null +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -0,0 +1,352 @@ +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"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { + type ThreadTitleGenerationResult, + type TextGenerationShape, + TextGeneration, +} from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; +import { + applyCursorAcpModelSelection, + makeCursorAcpRuntime, +} from "../../provider/acp/CursorAcpSupport.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +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); +} + +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 runCursorJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + 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 outputRef = yield* Ref.make(""); + const runtime = yield* makeCursorAcpRuntime({ + cursorSettings, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + 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 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({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Cursor ACP request was cancelled." + : "Cursor Agent returned empty output.", + }); + } + + 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, + }), + ), + ), + ); + }).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", + )(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), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CursorTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts new file mode 100644 index 00000000..4cf25c94 --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -0,0 +1,259 @@ +import type { ChildProcess } from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { beforeEach, expect, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + startCalls: [] as string[], + promptUrls: [] as string[], + authHeaders: [] as Array, + closeCalls: [] as string[], + promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.promptUrls.length = 0; + state.authHeaders.length = 0; + state.closeCalls.length = 0; + state.promptResult = undefined; + }, + }; +}); + +vi.mock("../../provider/opencodeRuntime.ts", async () => { + const actual = await vi.importActual( + "../../provider/opencodeRuntime.ts", + ); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + return { + url, + process: {} as ChildProcess, + close: () => { + runtimeMock.state.closeCalls.push(url); + }, + }; + }), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), + prompt: vi.fn(async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + info: { + structured: { + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }, + }, + }, + } + ); + }), + }, + }), + ), + }; +}); + +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "opencode" as const, + model: "openai/gpt-5", +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-existing-server-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + const textGeneration = yield* TextGeneration; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned no structured output."); + }), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGenerationLive with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts new file mode 100644 index 00000000..7721354e --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -0,0 +1,422 @@ +import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + TextGenerationError, + type ChatAttachment, + type OpenCodeModelSelection, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { ServerConfig } from "../../config.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "../Utils.ts"; +import { + createOpenCodeSdkClient, + type OpenCodeServerConnection, + type OpenCodeServerProcess, + parseOpenCodeModelSlug, + startOpenCodeServerProcess, + toOpenCodeFileParts, +} from "../../provider/opencodeRuntime.ts"; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +interface SharedOpenCodeTextGenerationServerState { + server: OpenCodeServerProcess | null; + binaryPath: string | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +const makeOpenCodeTextGeneration = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* ServerSettingsService; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCodeTextGenerationServerState = { + server: null, + binaryPath: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = (server: OpenCodeServerProcess) => { + if (sharedServerState.server === server) { + sharedServerState.server = null; + sharedServerState.binaryPath = null; + } + server.close(); + }; + + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( + server: OpenCodeServerProcess, + ) { + yield* cancelIdleCloseFiber(); + const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.sync(() => { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + closeSharedServer(server); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + sharedServerState.idleCloseFiber = fiber; + }); + + const acquireSharedServer = (input: { + readonly binaryPath: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + + const existingServer = sharedServerState.server; + if (existingServer !== null) { + if ( + sharedServerState.binaryPath !== input.binaryPath && + sharedServerState.activeRequests === 0 + ) { + closeSharedServer(existingServer); + } else { + if (sharedServerState.binaryPath !== input.binaryPath) { + yield* Effect.logWarning( + "OpenCode shared server binary path mismatch: requested " + + input.binaryPath + + " but active server uses " + + sharedServerState.binaryPath + + "; reusing existing server because there are active requests", + ); + } + sharedServerState.activeRequests += 1; + return existingServer; + } + } + + const server = yield* Effect.tryPromise({ + try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", + cause, + }), + }); + + sharedServerState.server = server; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); + + const releaseSharedServer = (server: OpenCodeServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) { + return; + } + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + const server = sharedServerState.server; + sharedServerState.server = null; + sharedServerState.binaryPath = null; + sharedServerState.activeRequests = 0; + if (server !== null) { + server.close(); + } + }), + ), + ); + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: OpenCodeModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const settings = yield* serverSettingsService.getSettings.pipe( + Effect.map( + (value) => + value.providers?.opencode ?? { + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + }, + ), + Effect.orElseSucceed(() => ({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + })), + ); + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const runAgainstServer = (server: Pick) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(settings.serverUrl.length > 0 && settings.serverPassword + ? { serverPassword: settings.serverPassword } + : {}), + }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(input.modelSelection.options?.agent + ? { agent: input.modelSelection.options.agent } + : {}), + ...(input.modelSelection.options?.variant + ? { variant: input.modelSelection.options.variant } + : {}), + format: { + type: "json_schema", + schema: toJsonSchemaObject(input.outputSchemaJson) as Record, + }, + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const structured = result.data?.info?.structured; + if (structured === undefined) { + throw new Error("OpenCode returned no structured output."); + } + return structured; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: + cause instanceof Error ? cause.message : "OpenCode text generation request failed.", + cause, + }), + }); + + const structuredOutput = + settings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: settings.serverUrl }) + : yield* Effect.acquireUseRelease( + acquireSharedServer({ + binaryPath: settings.binaryPath, + operation: input.operation, + }), + runAgainstServer, + releaseSharedServer, + ); + + return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + 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( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + 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( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.test.ts b/apps/server/src/git/Layers/RoutingTextGeneration.test.ts new file mode 100644 index 00000000..ee090d5a --- /dev/null +++ b/apps/server/src/git/Layers/RoutingTextGeneration.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeTextGenerationModelSelection, + resolveTextGenerationProvider, +} from "./RoutingTextGeneration.ts"; + +describe("resolveTextGenerationProvider", () => { + it("falls back unsupported providers to codex", () => { + expect(resolveTextGenerationProvider(undefined)).toBe("codex"); + expect(resolveTextGenerationProvider("copilot")).toBe("codex"); + }); + + it("preserves supported git text generation providers", () => { + expect(resolveTextGenerationProvider("codex")).toBe("codex"); + expect(resolveTextGenerationProvider("claudeAgent")).toBe("claudeAgent"); + expect(resolveTextGenerationProvider("cursor")).toBe("cursor"); + expect(resolveTextGenerationProvider("opencode")).toBe("opencode"); + }); +}); + +describe("normalizeTextGenerationModelSelection", () => { + it("normalizes copilot model selections to a valid codex selection", () => { + expect( + normalizeTextGenerationModelSelection({ + provider: "copilot", + model: "gpt-5", + options: { reasoningEffort: "medium" }, + }), + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + options: { reasoningEffort: "medium" }, + }); + }); + + it("keeps supported providers on their own normalized provider path", () => { + expect( + normalizeTextGenerationModelSelection({ + provider: "claudeAgent", + model: "claude-haiku-4.5", + }), + ).toEqual({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + }); + }); +}); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index c14b4d9e..c46c03f0 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -4,21 +4,28 @@ * request input. * * When `modelSelection.provider` is `"claudeAgent"` the request is forwarded to - * the Claude layer; unsupported or absent providers fall back to the Codex - * implementation as a defensive last resort. + * the Claude layer; for any other value (including the default `undefined`) it + * falls through to the Codex layer. * * @module RoutingTextGeneration */ import { Effect, Layer, Context } from "effect"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + type ProviderKind, + type ModelSelection, +} from "@t3tools/contracts"; +import { createModelSelection, resolveModelSlugForProvider } from "@t3tools/shared/model"; import { TextGeneration, - isTextGenerationProvider, type TextGenerationProvider, type TextGenerationShape, } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -32,6 +39,47 @@ class ClaudeTextGen extends Context.Service( "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", ) {} +class CursorTextGen extends Context.Service()( + "t3/git/Layers/RoutingTextGeneration/CursorTextGen", +) {} + +class OpenCodeTextGen extends Context.Service()( + "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", +) {} + +export const resolveTextGenerationProvider = ( + provider: ProviderKind | undefined, +): TextGenerationProvider => + provider === "claudeAgent" || provider === "cursor" || provider === "opencode" + ? provider + : "codex"; + +export const normalizeTextGenerationModelSelection = ( + modelSelection: ModelSelection, +): ModelSelection => { + const provider = resolveTextGenerationProvider(modelSelection.provider); + if (provider === modelSelection.provider) { + return createModelSelection( + provider, + resolveModelSlugForProvider(provider, modelSelection.model), + modelSelection.options, + ); + } + + if (modelSelection.provider === "copilot") { + const options = modelSelection.options?.reasoningEffort + ? { reasoningEffort: modelSelection.options.reasoningEffort } + : undefined; + return createModelSelection( + "codex", + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + options, + ); + } + + return createModelSelection(provider, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider]); +}; + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -39,26 +87,54 @@ class ClaudeTextGen extends Context.Service( const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; + const cursor = yield* CursorTextGen; + const openCode = yield* OpenCodeTextGen; - const route = (provider?: TextGenerationProvider): TextGenerationShape => { - if (provider === "claudeAgent") { - return claude; - } - return codex; + const route = (provider?: TextGenerationProvider): TextGenerationShape => + provider === "claudeAgent" + ? claude + : provider === "opencode" + ? openCode + : provider === "cursor" + ? cursor + : codex; + const normalizeInput = < + TInput extends { + readonly modelSelection: ModelSelection; + }, + >( + input: TInput, + ): { readonly provider: TextGenerationProvider; readonly input: TInput } => { + const normalizedModelSelection = normalizeTextGenerationModelSelection(input.modelSelection); + return { + provider: resolveTextGenerationProvider(normalizedModelSelection.provider), + input: + normalizedModelSelection === input.modelSelection + ? input + : { + ...input, + modelSelection: normalizedModelSelection, + }, + }; }; - const resolveProvider = (provider: string | undefined): TextGenerationProvider => - isTextGenerationProvider(provider as never) ? (provider as TextGenerationProvider) : "codex"; - return { - generateCommitMessage: (input) => - route(resolveProvider(input.modelSelection.provider)).generateCommitMessage(input), - generatePrContent: (input) => - route(resolveProvider(input.modelSelection.provider)).generatePrContent(input), - generateBranchName: (input) => - route(resolveProvider(input.modelSelection.provider)).generateBranchName(input), - generateThreadTitle: (input) => - route(resolveProvider(input.modelSelection.provider)).generateThreadTitle(input), + generateCommitMessage: (input) => { + const normalized = normalizeInput(input); + return route(normalized.provider).generateCommitMessage(normalized.input); + }, + generatePrContent: (input) => { + const normalized = normalizeInput(input); + return route(normalized.provider).generatePrContent(normalized.input); + }, + generateBranchName: (input) => { + const normalized = normalizeInput(input); + return route(normalized.provider).generateBranchName(normalized.input); + }, + generateThreadTitle: (input) => { + const normalized = normalizeInput(input); + return route(normalized.provider).generateThreadTitle(normalized.input); + }, } satisfies TextGenerationShape; }); @@ -78,7 +154,28 @@ 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)); + +const InternalOpenCodeLayer = Layer.effect( + OpenCodeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(OpenCodeTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalCursorLayer), + Layer.provide(InternalOpenCodeLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b6802b6a..78d37a01 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,23 +8,12 @@ */ import { Context } from "effect"; import type { Effect } from "effect"; -import { - GIT_TEXT_GENERATION_PROVIDERS, - type ChatAttachment, - type ModelSelection, - type ProviderKind, -} from "@t3tools/contracts"; +import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = (typeof GIT_TEXT_GENERATION_PROVIDERS)[number]; - -export function isTextGenerationProvider( - provider: ProviderKind | undefined, -): provider is TextGenerationProvider { - return provider === "codex" || provider === "claudeAgent"; -} +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d60f0cf7..8be139e3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -17,7 +18,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, and checkpoint reactors", async () => { + it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -49,10 +50,19 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadDeletionReactor, { + start: () => { + started.push("thread-deletion-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), ), ); - const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor)); + const reactor = await runtime!.runPromise(Effect.service(OrchestrationReactor)); const scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); @@ -60,6 +70,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "thread-deletion-reactor", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 99d30c57..25829483 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,16 +7,19 @@ import { import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; + const threadDeletionReactor = yield* ThreadDeletionReactor; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); + yield* threadDeletionReactor.start(); }); return { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e00c1681..dfdfab92 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -101,7 +101,6 @@ describe("ProviderCommandReactor", () => { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; - readonly serverSettingsOverrides?: Parameters[0]; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); @@ -263,11 +262,11 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), - Layer.provideMerge(ServerSettingsService.layerTest(input?.serverSettingsOverrides)), + Layer.provideMerge(ServerSettingsService.layerTest()), 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)); @@ -751,6 +750,57 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves the active session model when in-session model switching is unsupported", 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-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + 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.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }); + }); + 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" }, @@ -803,57 +853,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("preserves the active session model when in-session model switching is unsupported", 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-1"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-1"), - role: "user", - text: "first", - 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.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-2"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-2"), - role: "user", - text: "second", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 2); - - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - }); - }); - it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1101,23 +1100,33 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { + it("does not stop the active session when restart fails before rebind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-1"), + commandId: CommandId.make("cmd-turn-start-restart-failure-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-1"), + messageId: asMessageId("user-message-restart-failure-1"), role: "user", text: "first", attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); @@ -1125,22 +1134,15 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, + ); + await Effect.runPromise( harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-2"), + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-switch-2"), - role: "user", - text: "second", - attachments: [], - }, - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -1149,57 +1151,37 @@ describe("ProviderCommandReactor", () => { 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 - ); + return thread?.runtimeMode === "approval-required"; }); + await waitFor(() => harness.startSession.mock.calls.length === 2); + await harness.drain(); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("does not stop the active session when restart fails before rebind", async () => { + it("rejects provider changes after a thread is already bound to a session provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - createdAt: now, - }), - ); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-restart-failure-1"), + commandId: CommandId.make("cmd-turn-start-provider-switch-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-restart-failure-1"), + messageId: asMessageId("user-message-provider-switch-1"), role: "user", text: "first", attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: "approval-required", createdAt: now, }), ); @@ -1207,15 +1189,22 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - harness.startSession.mockImplementationOnce( - (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, - ); - await Effect.runPromise( harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-switch-2"), threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -1224,18 +1213,28 @@ describe("ProviderCommandReactor", () => { 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?.runtimeMode === "approval-required"; + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); }); - await waitFor(() => harness.startSession.mock.calls.length === 2); - await harness.drain(); - expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.sendTurn.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.runtimeMode).toBe("full-access"); + expect(thread?.session?.providerName).toBe("codex"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index db2bd2d4..8b3321ec 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -89,9 +89,16 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea : false; } +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); - if (Schema.is(ProviderAdapterRequestError)(error)) { + const error = findProviderAdapterRequestError(cause); + if (error) { const detail = error.detail.toLowerCase(); return ( detail.includes("unknown pending approval request") || @@ -106,8 +113,8 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { + const error = findProviderAdapterRequestError(cause); + if (error) { return error.detail.toLowerCase().includes("unknown pending user-input request"); } return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request"); @@ -198,6 +205,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.detail; + } + return Cause.pretty(cause); + }; + const setThreadSession = (input: { readonly threadId: ThreadId; readonly session: OrchestrationSession; @@ -211,7 +229,30 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const resolveThread = Effect.fn("resolveThread")(function* (threadId: ThreadId) { + 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); }); @@ -247,7 +288,7 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, }); } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; + const preferredProvider: ProviderKind = threadProvider; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -293,9 +334,6 @@ const make = Effect.gen(function* () { thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; - const providerChanged = - requestedModelSelection !== undefined && - requestedModelSelection.provider !== currentProvider; const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -303,7 +341,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" && @@ -312,17 +350,15 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && - !providerChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -331,7 +367,6 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, - providerChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, @@ -356,7 +391,7 @@ const make = Effect.gen(function* () { return startedSession.threadId; }); - const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: { + const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -366,7 +401,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, @@ -390,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, @@ -399,13 +436,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.fn( @@ -564,7 +601,43 @@ const make = Effect.gen(function* () { } } - yield* sendTurnForThread({ + const handleTurnStartFailure = (cause: Cause.Cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + 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.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, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), @@ -574,17 +647,17 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.catchCause((cause) => - appendProviderFailureActivity({ - threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", - detail: Cause.pretty(cause), - 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(recoverTurnStartFailure), Effect.forkScoped); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 2a330a36..577c5050 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -723,6 +723,146 @@ 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", + 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(activity?.summary).toBe("Read file"); + expect(payload?.itemType).toBe("dynamic_tool_call"); + 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(); @@ -1392,6 +1532,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.make("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.make("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.make("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.make("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.make("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 f2c84ea4..7eeeed2d 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.make(`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,15 @@ 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.make( + segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`, + ); +} function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -501,7 +520,7 @@ function runtimeEventToActivities( return []; } -const make = Effect.fn("make")(function* () { +const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -519,6 +538,15 @@ const make = Effect.fn("make")(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, @@ -586,10 +614,86 @@ const make = Effect.fn("make")(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( - Effect.fn("appendBufferedAssistantText")(function* (existingText) { + Effect.flatMap((existingText) => + Effect.gen(function* () { const nextText = Option.match(existingText, { onNone: () => delta, onSome: (text) => `${text}${delta}`, @@ -645,48 +749,154 @@ const make = Effect.fn("make")(function* () { const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId); - const finalizeAssistantMessage = Effect.fn("finalizeAssistantMessage")(function* (input: { + const flushBufferedAssistantMessage = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; messageId: MessageId; turnId?: TurnId; createdAt: string; commandTag: string; - finalDeltaCommandTag: string; - fallbackText?: string; - }) { - const bufferedText = yield* takeBufferedAssistantText(input.messageId); - const text = - bufferedText.length > 0 - ? bufferedText - : (input.fallbackText?.trim().length ?? 0) > 0 - ? input.fallbackText! - : ""; - - if (text.length > 0) { + }) => + 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.finalDeltaCommandTag), + commandId: providerCommandId(input.event, input.commandTag), threadId: input.threadId, messageId: input.messageId, - delta: text, + delta: bufferedText, ...(input.turnId ? { turnId: input.turnId } : {}), createdAt: input.createdAt, }); - } + return true; + }); - 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, + 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; }); - yield* clearAssistantMessageState(input.messageId); - }); - const upsertProposedPlan = Effect.fn("upsertProposedPlan")(function* (input: { + const finalizeAssistantMessage = (input: { + event: ProviderRuntimeEvent; + threadId: ThreadId; + messageId: MessageId; + turnId?: TurnId; + createdAt: string; + commandTag: string; + finalDeltaCommandTag: string; + fallbackText?: string; + hasProjectedMessage?: boolean; + }) => + Effect.gen(function* () { + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const text = + bufferedText.length > 0 + ? bufferedText + : (input.fallbackText?.trim().length ?? 0) > 0 + ? input.fallbackText! + : ""; + const hasRenderableText = hasRenderableAssistantText(text); + + if (hasRenderableText) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + threadId: input.threadId, + messageId: input.messageId, + delta: text, + ...(input.turnId ? { turnId: input.turnId } : {}), + createdAt: input.createdAt, + }); + } + + 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: activeMessageId.value, + turnId: input.turnId, + createdAt: input.createdAt, + commandTag: input.commandTag, + finalDeltaCommandTag: input.finalDeltaCommandTag, + hasProjectedMessage: + input.hasProjectedMessage || + (input.flushedMessageIds?.has(activeMessageId.value) ?? false), + }); + 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: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -700,31 +910,32 @@ const make = Effect.fn("make")(function* () { planMarkdown: string | undefined; createdAt: string; updatedAt: string; - }) { - const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); + if (!planMarkdown) { + return; + } - const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: providerCommandId(input.event, "proposed-plan-upsert"), - threadId: input.threadId, - proposedPlan: { - id: input.planId, - turnId: input.turnId ?? null, - planMarkdown, - implementedAt: existingPlan?.implementedAt ?? null, - implementationThreadId: existingPlan?.implementationThreadId ?? null, - createdAt: existingPlan?.createdAt ?? input.createdAt, - updatedAt: input.updatedAt, - }, - createdAt: input.updatedAt, + const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? input.createdAt, + updatedAt: input.updatedAt, + }, + createdAt: input.updatedAt, + }); }); - }); - const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { + const finalizeBufferedProposedPlan = (input: { event: ProviderRuntimeEvent; threadId: ThreadId; threadProposedPlans: ReadonlyArray<{ @@ -737,65 +948,75 @@ const make = Effect.fn("make")(function* () { turnId?: TurnId; fallbackMarkdown?: string; updatedAt: string; - }) { - const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); - const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; - if (!planMarkdown) { - return; - } + }) => + Effect.gen(function* () { + const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); + const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); + const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); + const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; + if (!planMarkdown) { + return; + } - yield* upsertProposedPlan({ - event: input.event, - threadId: input.threadId, - threadProposedPlans: input.threadProposedPlans, - planId: input.planId, - ...(input.turnId ? { turnId: input.turnId } : {}), - planMarkdown, - createdAt: - bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 - ? bufferedPlan.createdAt - : input.updatedAt, - updatedAt: input.updatedAt, + yield* upsertProposedPlan({ + event: input.event, + threadId: input.threadId, + threadProposedPlans: input.threadProposedPlans, + planId: input.planId, + ...(input.turnId ? { turnId: input.turnId } : {}), + planMarkdown, + createdAt: + bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 + ? bufferedPlan.createdAt + : input.updatedAt, + updatedAt: input.updatedAt, + }); + yield* clearBufferedProposedPlan(input.planId); }); - yield* clearBufferedProposedPlan(input.planId); - }); - const clearTurnStateForSession = Effect.fn("clearTurnStateForSession")(function* ( - threadId: ThreadId, - ) { - const prefix = `${threadId}:`; - const proposedPlanPrefix = `plan:${threadId}:`; - const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey)); - const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById)); - yield* Effect.forEach( - turnKeys, - Effect.fn(function* (key) { - if (!key.startsWith(prefix)) { - return; - } + const clearTurnStateForSession = (threadId: ThreadId) => + 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, + (key) => + Effect.gen(function* () { + if (!key.startsWith(prefix)) { + return; + } - const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); - if (Option.isSome(messageIds)) { - yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { - concurrency: 1, - }).pipe(Effect.asVoid); - } + const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key); + if (Option.isSome(messageIds)) { + yield* Effect.forEach(messageIds.value, clearAssistantMessageState, { + concurrency: 1, + }).pipe(Effect.asVoid); + } - yield* Cache.invalidate(turnMessageIdsByTurnKey, key); - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* Effect.forEach( - proposedPlanKeys, - (key) => - key.startsWith(proposedPlanPrefix) - ? Cache.invalidate(bufferedProposedPlanById, key) - : Effect.void, - { concurrency: 1 }, - ).pipe(Effect.asVoid); - }); + yield* Cache.invalidate(turnMessageIdsByTurnKey, key); + }), + { 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) => + key.startsWith(proposedPlanPrefix) + ? Cache.invalidate(bufferedProposedPlanById, key) + : Effect.void, + { concurrency: 1 }, + ).pipe(Effect.asVoid); + }); const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn( "getSourceProposedPlanReferenceForPendingTurnStart", @@ -873,353 +1094,432 @@ const make = Effect.fn("make")(function* () { }, ); - const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( - event: ProviderRuntimeEvent, - ) { - const readModel = yield* orchestrationEngine.getReadModel(); - const thread = readModel.threads.find((entry) => entry.id === event.threadId); - if (!thread) return; + const processRuntimeEvent = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.threadId); + if (!thread) return; - const now = event.createdAt; - const eventTurnId = toTurnId(event.turnId); - const activeTurnId = thread.session?.activeTurnId ?? null; + const now = event.createdAt; + const eventTurnId = toTurnId(event.turnId); + const activeTurnId = thread.session?.activeTurnId ?? null; - const conflictsWithActiveTurn = - activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); - const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; + const conflictsWithActiveTurn = + activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId); + const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined; - const shouldApplyThreadLifecycle = (() => { - if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { - return true; - } - switch (event.type) { - case "session.exited": - return true; - case "session.started": - case "thread.started": - return true; - case "turn.started": - return !conflictsWithActiveTurn; - case "turn.completed": - if (conflictsWithActiveTurn || missingTurnForActiveTurn) { - return false; - } - // Only the active turn may close the lifecycle state. - if (activeTurnId !== null && eventTurnId !== undefined) { - return sameId(activeTurnId, eventTurnId); - } - // If no active turn is tracked, accept completion scoped to this thread. + const shouldApplyThreadLifecycle = (() => { + if (!STRICT_PROVIDER_LIFECYCLE_GUARD) { return true; - default: - return true; - } - })(); - const acceptedTurnStartedSourcePlan = - event.type === "turn.started" && shouldApplyThreadLifecycle - ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) - : null; - - if ( - event.type === "session.started" || - event.type === "session.state.changed" || - event.type === "session.exited" || - event.type === "thread.started" || - event.type === "turn.started" || - event.type === "turn.completed" - ) { - const nextActiveTurnId = - event.type === "turn.started" - ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" - ? null - : activeTurnId; - const status = (() => { + } switch (event.type) { - case "session.state.changed": - return orchestrationSessionStatusFromRuntimeState(event.payload.state); - case "turn.started": - return "running"; case "session.exited": - return "stopped"; - case "turn.completed": - return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + return true; case "session.started": case "thread.started": - // Provider thread/session start notifications can arrive during an - // active turn; preserve turn-running state in that case. - return activeTurnId !== null ? "running" : "ready"; + return true; + case "turn.started": + return !conflictsWithActiveTurn; + case "turn.completed": + if (conflictsWithActiveTurn || missingTurnForActiveTurn) { + return false; + } + // Only the active turn may close the lifecycle state. + if (activeTurnId !== null && eventTurnId !== undefined) { + return sameId(activeTurnId, eventTurnId); + } + // If no active turn is tracked, accept completion scoped to this thread. + return true; + default: + return true; } })(); - const lastError = - event.type === "session.state.changed" && event.payload.state === "error" - ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.completed" && - normalizeRuntimeTurnState(event.payload.state) === "failed" - ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + const acceptedTurnStartedSourcePlan = + event.type === "turn.started" && shouldApplyThreadLifecycle + ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) + : null; + + if ( + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ) { + const nextActiveTurnId = + event.type === "turn.started" + ? (eventTurnId ?? null) + : event.type === "turn.completed" || event.type === "session.exited" ? null - : (thread.session?.lastError ?? null); - - if (shouldApplyThreadLifecycle) { - if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { - yield* markSourceProposedPlanImplemented( - acceptedTurnStartedSourcePlan.sourceThreadId, - acceptedTurnStartedSourcePlan.sourcePlanId, - thread.id, - now, - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider runtime ingestion failed to mark source proposed plan", { - eventId: event.eventId, - eventType: event.type, - cause: Cause.pretty(cause), - }), - ), - ); - } + : activeTurnId; + const status = (() => { + switch (event.type) { + case "session.state.changed": + return orchestrationSessionStatusFromRuntimeState(event.payload.state); + case "turn.started": + return "running"; + case "session.exited": + return "stopped"; + case "turn.completed": + return normalizeRuntimeTurnState(event.payload.state) === "failed" + ? "error" + : "ready"; + case "session.started": + case "thread.started": + // Provider thread/session start notifications can arrive during an + // active turn; preserve turn-running state in that case. + return activeTurnId !== null ? "running" : "ready"; + } + })(); + const lastError = + event.type === "session.state.changed" && event.payload.state === "error" + ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") + : event.type === "turn.completed" && + normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" + ? null + : (thread.session?.lastError ?? null); + + if (shouldApplyThreadLifecycle) { + if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) { + yield* markSourceProposedPlanImplemented( + acceptedTurnStartedSourcePlan.sourceThreadId, + acceptedTurnStartedSourcePlan.sourcePlanId, + thread.id, + now, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider runtime ingestion failed to mark source proposed plan", + { + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }, + ), + ), + ); + } - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "thread-session-set"), - threadId: thread.id, - session: { + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-set"), threadId: thread.id, - status, - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: nextActiveTurnId, - lastError, - updatedAt: now, - }, - createdAt: now, - }); + session: { + threadId: thread.id, + status, + providerName: event.provider, + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: nextActiveTurnId, + lastError, + updatedAt: now, + }, + createdAt: now, + }); + } } - } - const assistantDelta = - event.type === "content.delta" && event.payload.streamKind === "assistant_text" - ? event.payload.delta - : undefined; - const proposedPlanDelta = - event.type === "turn.proposed.delta" ? event.payload.delta : undefined; + const assistantDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? event.payload.delta + : undefined; + const proposedPlanDelta = + event.type === "turn.proposed.delta" ? event.payload.delta : undefined; - if (assistantDelta && assistantDelta.length > 0) { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const turnId = toTurnId(event.turnId); - if (turnId) { - yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); - } + if (assistantDelta && assistantDelta.length > 0) { + const turnId = toTurnId(event.turnId); + const assistantMessageId = yield* getOrCreateAssistantMessageId({ + threadId: thread.id, + event, + ...(turnId ? { turnId } : {}), + }); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); + } - const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( - serverSettingsService.getSettings, - (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), - ); - if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); - if (spillChunk.length > 0) { + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); + if (assistantDeliveryMode === "buffered") { + const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + if (spillChunk.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.delta", + commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + threadId: thread.id, + messageId: assistantMessageId, + delta: spillChunk, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + } + } else { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - delta: spillChunk, + delta: assistantDelta, ...(turnId ? { turnId } : {}), createdAt: now, }); } - } else { - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta"), + } + + 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, - messageId: assistantMessageId, - delta: assistantDelta, - ...(turnId ? { turnId } : {}), + 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); - } + if (proposedPlanDelta && proposedPlanDelta.length > 0) { + const planId = proposedPlanIdFromEvent(event, thread.id); + yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now); + } - const assistantCompletion = - event.type === "item.completed" && event.payload.itemType === "assistant_message" - ? { - messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`), - fallbackText: event.payload.detail, - } - : undefined; - const proposedPlanCompletion = - event.type === "turn.proposed.completed" - ? { - planId: proposedPlanIdFromEvent(event, thread.id), - turnId: toTurnId(event.turnId), - planMarkdown: event.payload.planMarkdown, + const assistantCompletion = + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? { + messageId: MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ), + fallbackText: event.payload.detail, + } + : undefined; + const proposedPlanCompletion = + event.type === "turn.proposed.completed" + ? { + planId: proposedPlanIdFromEvent(event, thread.id), + turnId: toTurnId(event.turnId), + planMarkdown: event.payload.planMarkdown, + } + : undefined; + + if (assistantCompletion) { + 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; + + 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); } - : undefined; - if (assistantCompletion) { - const assistantMessageId = assistantCompletion.messageId; - const turnId = toTurnId(event.turnId); - 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", + hasProjectedMessage: existingAssistantMessage !== undefined, + ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText + ? { fallbackText: assistantCompletion.fallbackText } + : {}), + }); - 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 } - : {}), - }); + if (turnId) { + yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + } + } - if (turnId) { - yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId); + if (turnId) { + yield* clearAssistantSegmentStateForTurn(thread.id, turnId); + } } - } - - if (proposedPlanCompletion) { - yield* finalizeBufferedProposedPlan({ - event, - threadId: thread.id, - threadProposedPlans: thread.proposedPlans, - planId: proposedPlanCompletion.planId, - ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), - fallbackMarkdown: proposedPlanCompletion.planMarkdown, - updatedAt: now, - }); - } - - if (event.type === "turn.completed") { - const turnId = toTurnId(event.turnId); - if (turnId) { - const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); - yield* Effect.forEach( - assistantMessageIds, - (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - }), - { concurrency: 1 }, - ).pipe(Effect.asVoid); - yield* clearAssistantMessageIdsForTurn(thread.id, turnId); + if (proposedPlanCompletion) { yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, threadProposedPlans: thread.proposedPlans, - planId: proposedPlanIdForTurn(thread.id, turnId), - turnId, + planId: proposedPlanCompletion.planId, + ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), + fallbackMarkdown: proposedPlanCompletion.planMarkdown, updatedAt: now, }); } - } - - if (event.type === "session.exited") { - yield* clearTurnStateForSession(thread.id); - } - - if (event.type === "runtime.error") { - const runtimeErrorMessage = event.payload.message; - const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD - ? true - : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + if (event.type === "turn.completed") { + const turnId = toTurnId(event.turnId); + if (turnId) { + const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); + yield* Effect.forEach( + assistantMessageIds, + (assistantMessageId) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + 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); - if (shouldApplyRuntimeError) { - yield* orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: providerCommandId(event, "runtime-error-session-set"), - threadId: thread.id, - session: { + yield* finalizeBufferedProposedPlan({ + event, threadId: thread.id, - status: "error", - providerName: event.provider, - runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, - lastError: runtimeErrorMessage, + threadProposedPlans: thread.proposedPlans, + planId: proposedPlanIdForTurn(thread.id, turnId), + turnId, updatedAt: now, - }, - createdAt: now, - }); + }); + } } - } - if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); - } + if (event.type === "session.exited") { + yield* clearTurnStateForSession(thread.id); + } - if (event.type === "turn.diff.updated") { - const turnId = toTurnId(event.turnId); - if (turnId && (yield* isGitRepoForThread(thread.id))) { - // Skip if a checkpoint already exists for this turn. A real - // (non-placeholder) capture from CheckpointReactor should not - // be clobbered, and dispatching a duplicate placeholder for the - // same turnId would produce an unstable checkpointTurnCount. - if (thread.checkpoints.some((c) => c.turnId === turnId)) { - // Already tracked; no-op. - } else { - const assistantMessageId = MessageId.make( - `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, - ); - const maxTurnCount = thread.checkpoints.reduce( - (max, c) => Math.max(max, c.checkpointTurnCount), - 0, - ); + if (event.type === "runtime.error") { + const runtimeErrorMessage = event.payload.message; + + const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD + ? true + : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId); + + if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ - type: "thread.turn.diff.complete", - commandId: providerCommandId(event, "thread-turn-diff-complete"), + type: "thread.session.set", + commandId: providerCommandId(event, "runtime-error-session-set"), threadId: thread.id, - turnId, - completedAt: now, - checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), - status: "missing", - files: [], - assistantMessageId, - checkpointTurnCount: maxTurnCount + 1, + session: { + threadId: thread.id, + status: "error", + providerName: event.provider, + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: eventTurnId ?? null, + lastError: runtimeErrorMessage, + updatedAt: now, + }, createdAt: now, }); } } - } - const activities = runtimeEventToActivities(event); - yield* Effect.forEach(activities, (activity) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: providerCommandId(event, "thread-activity-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }), - ).pipe(Effect.asVoid); - }); + if (event.type === "thread.metadata.updated" && event.payload.name) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } + + if (event.type === "turn.diff.updated") { + const turnId = toTurnId(event.turnId); + if (turnId && (yield* isGitRepoForThread(thread.id))) { + // Skip if a checkpoint already exists for this turn. A real + // (non-placeholder) capture from CheckpointReactor should not + // be clobbered, and dispatching a duplicate placeholder for the + // same turnId would produce an unstable checkpointTurnCount. + if (thread.checkpoints.some((c) => c.turnId === turnId)) { + // Already tracked; no-op. + } else { + const assistantMessageId = MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ); + const maxTurnCount = thread.checkpoints.reduce( + (max, c) => Math.max(max, c.checkpointTurnCount), + 0, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: providerCommandId(event, "thread-turn-diff-complete"), + threadId: thread.id, + turnId, + completedAt: now, + checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`), + status: "missing", + files: [], + assistantMessageId, + checkpointTurnCount: maxTurnCount + 1, + createdAt: now, + }); + } + } + } + + const activities = runtimeEventToActivities(event); + yield* Effect.forEach(activities, (activity) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: providerCommandId(event, "thread-activity-append"), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }), + ).pipe(Effect.asVoid); + }); const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; @@ -1243,21 +1543,22 @@ const make = Effect.fn("make")(function* () { const worker = yield* makeDrainableWorker(processInputSafely); - const start: ProviderRuntimeIngestionShape["start"] = Effect.fn("start")(function* () { - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - worker.enqueue({ source: "runtime", event }), - ), - ); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); - }); + const start: ProviderRuntimeIngestionShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => + worker.enqueue({ source: "runtime", event }), + ), + ); + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }), + ); + }); return { start, @@ -1267,5 +1568,5 @@ const make = Effect.fn("make")(function* () { export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, - make(), + make, ).pipe(Layer.provide(ProjectionTurnRepositoryLive)); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts new file mode 100644 index 00000000..4fdac411 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -0,0 +1,36 @@ +import { ThreadId } from "@t3tools/contracts"; +import { Cause, Effect, Exit } from "effect"; +import { describe, expect, it } from "vitest"; + +import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; + +describe("logCleanupCauseUnlessInterrupted", () => { + const threadId = ThreadId.make("thread-deletion-reactor-test"); + + it("swallows ordinary cleanup failures", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.fail("cleanup failed"), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isSuccess(exit)).toBe(true); + }); + + it("preserves interrupt causes", async () => { + const exit = await Effect.runPromiseExit( + logCleanupCauseUnlessInterrupted({ + effect: Effect.interrupt, + message: "thread deletion cleanup skipped provider session stop", + threadId, + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); + } + }); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts new file mode 100644 index 00000000..db3d14fa --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -0,0 +1,96 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { Cause, Effect, Layer, Stream } from "effect"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + ThreadDeletionReactor, + type ThreadDeletionReactorShape, +} from "../Services/ThreadDeletionReactor.ts"; + +type ThreadDeletedEvent = Extract; + +export const logCleanupCauseUnlessInterrupted = ({ + effect, + message, + threadId, +}: { + readonly effect: Effect.Effect; + readonly message: string; + readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; +}): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logDebug(message, { + threadId, + cause: Cause.pretty(cause), + }); + }), + ); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const terminalManager = yield* TerminalManager; + + const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: providerService.stopSession({ threadId }), + message: "thread deletion cleanup skipped provider session stop", + threadId, + }); + + const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: terminalManager.close({ threadId, deleteHistory: true }), + message: "thread deletion cleanup skipped terminal close", + threadId, + }); + + const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( + event: ThreadDeletedEvent, + ) { + const { threadId } = event.payload; + yield* stopProviderSession(threadId); + yield* closeThreadTerminals(threadId); + }); + + const processThreadDeletedSafely = (event: ThreadDeletedEvent) => + processThreadDeleted(event).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("thread deletion reactor failed to process event", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }); + }), + ); + + const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.deleted") { + return Effect.void; + } + return worker.enqueue(event); + }), + ); + }); + + return { + start, + drain: worker.drain, + } satisfies ThreadDeletionReactorShape; +}); + +export const ThreadDeletionReactorLive = Layer.effect(ThreadDeletionReactor, make); diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts new file mode 100644 index 00000000..6cf1f0bb --- /dev/null +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -0,0 +1,37 @@ +/** + * ThreadDeletionReactor - Thread deletion cleanup reactor service interface. + * + * Owns background workers that react to thread deletion domain events and + * perform best-effort runtime cleanup for provider sessions and terminals. + * + * @module ThreadDeletionReactor + */ +import { Context } from "effect"; +import type { Effect, Scope } from "effect"; + +/** + * ThreadDeletionReactorShape - Service API for thread deletion cleanup. + */ +export interface ThreadDeletionReactorShape { + /** + * Start reacting to thread.deleted orchestration domain events. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: () => Effect.Effect; + + /** + * Resolves when the internal processing queue is empty and idle. + * Intended for test use to replace timing-sensitive sleeps. + */ + readonly drain: Effect.Effect; +} + +/** + * ThreadDeletionReactor - Service tag for thread deletion cleanup workers. + */ +export class ThreadDeletionReactor extends Context.Service< + ThreadDeletionReactor, + ThreadDeletionReactorShape +>()("t3/orchestration/Services/ThreadDeletionReactor") {} diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts new file mode 100644 index 00000000..2b323714 --- /dev/null +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -0,0 +1,226 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +async function seedReadModel(): Promise { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-delete"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-delete"), + title: "Project Delete", + workspaceRoot: "/tmp/project-delete", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const withFirstThread = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + return Effect.runPromise( + projectEvent(withFirstThread, { + sequence: 3, + eventId: asEventId("evt-thread-create-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + projectId: asProjectId("project-delete"), + title: "Thread Delete 2", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +type PlannedEvent = Omit; + +function normalizeDeleteEvent(event: PlannedEvent | ReadonlyArray) { + const events = Array.isArray(event) ? event : [event]; + return events.map((entry) => { + switch (entry.type) { + case "thread.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + threadId: entry.payload.threadId, + }, + }; + case "project.deleted": + return { + type: entry.type, + aggregateKind: entry.aggregateKind, + aggregateId: entry.aggregateId, + commandId: entry.commandId, + correlationId: entry.correlationId, + payload: { + projectId: entry.payload.projectId, + }, + }; + default: + return entry; + } + }); +} + +describe("decider deletion flows", () => { + it("rejects deleting a non-empty project without force", async () => { + const readModel = await seedReadModel(); + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-no-force"), + projectId: asProjectId("project-delete"), + }, + readModel, + }), + ), + ).rejects.toThrow("cannot be deleted without force=true"); + }); + + it("reuses thread.delete semantics when force-deleting a non-empty project", async () => { + const readModel = await seedReadModel(); + const projectDeleteCommand: Extract = { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-force"), + projectId: asProjectId("project-delete"), + force: true, + }; + + const forcedResult = await Effect.runPromise( + decideOrchestrationCommand({ + command: projectDeleteCommand, + readModel, + }), + ); + const forcedEvents = Array.isArray(forcedResult) ? forcedResult : [forcedResult]; + + expect(forcedEvents.map((event) => event.type)).toEqual([ + "thread.deleted", + "thread.deleted", + "project.deleted", + ]); + + let sequentialReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const sequentialEvents: PlannedEvent[] = []; + for (const nextCommand of [ + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-1"), + }, + { + type: "thread.delete", + commandId: projectDeleteCommand.commandId, + threadId: asThreadId("thread-delete-2"), + }, + { + type: "project.delete", + commandId: projectDeleteCommand.commandId, + projectId: asProjectId("project-delete"), + }, + ] satisfies ReadonlyArray) { + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: nextCommand, + readModel: sequentialReadModel, + }), + ); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + sequentialEvents.push(...nextEvents); + for (const nextEvent of nextEvents) { + nextSequence += 1; + sequentialReadModel = await Effect.runPromise( + projectEvent(sequentialReadModel, { + ...nextEvent, + sequence: nextSequence, + }), + ); + } + } + + expect(normalizeDeleteEvent(forcedResult)).toEqual(normalizeDeleteEvent(sequentialEvents)); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 22f5bcb2..9b6b1eb1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -7,6 +7,7 @@ import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { + listThreadsByProjectId, requireProject, requireProjectAbsent, requireThread, @@ -14,6 +15,7 @@ import { requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; +import { projectEvent } from "./projector.ts"; const nowIso = () => new Date().toISOString(); const defaultMetadata: Omit = { @@ -47,16 +49,49 @@ function withEventBase( }; } +type PlannedOrchestrationEvent = Omit; + +type DecideOrchestrationCommandResult = + | PlannedOrchestrationEvent + | ReadonlyArray; + +const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ + commands, + readModel, +}: { + readonly commands: ReadonlyArray; + readonly readModel: OrchestrationReadModel; +}): Effect.fn.Return, OrchestrationCommandInvariantError> { + let nextReadModel = readModel; + let nextSequence = readModel.snapshotSequence; + const plannedEvents: PlannedOrchestrationEvent[] = []; + + for (const nextCommand of commands) { + const decided = yield* decideOrchestrationCommand({ + command: nextCommand, + readModel: nextReadModel, + }); + const nextEvents = Array.isArray(decided) ? decided : [decided]; + for (const nextEvent of nextEvents) { + plannedEvents.push(nextEvent); + nextSequence += 1; + nextReadModel = yield* projectEvent(nextReadModel, { + ...nextEvent, + sequence: nextSequence, + }).pipe(Effect.orDie); + } + } + + return plannedEvents; +}); + export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return< - Omit | ReadonlyArray>, - OrchestrationCommandInvariantError -> { +}): Effect.fn.Return { switch (command.type) { case "project.create": { yield* requireProjectAbsent({ @@ -119,6 +154,35 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( + (thread) => thread.deletedAt === null, + ); + if (activeThreads.length > 0 && command.force !== true) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, + }); + } + if (activeThreads.length > 0) { + return yield* decideCommandSequence({ + readModel, + commands: [ + ...activeThreads.map( + (thread): Extract => ({ + type: "thread.delete", + commandId: command.commandId, + threadId: thread.id, + }), + ), + { + type: "project.delete", + commandId: command.commandId, + projectId: command.projectId, + }, + ], + }); + } + const occurredAt = nowIso(); return { ...withEventBase({ @@ -127,7 +191,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt, commandId: command.commandId, }), - type: "project.deleted", + type: "project.deleted" as const, payload: { projectId: command.projectId, deletedAt: occurredAt, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 03ba0ce4..8f1af19d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -141,10 +141,10 @@ class FakeCodexManager extends CodexAppServerManager { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + remove: () => Effect.void, getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index 3e12a13a..4357b411 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -326,6 +326,75 @@ it.effect("CopilotAdapterLive MCP config loading", () => ), ); +const startupFailureSession = new FakeCopilotSession("copilot-session-startup-failure"); +const startupFailureClient = new FakeCopilotClient(startupFailureSession); +const startupFailureLayer = it.layer( + makeCopilotAdapterLive({ + clientFactory: () => startupFailureClient, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ), +); + +startupFailureLayer("CopilotAdapterLive startup cleanup", (it) => { + it.effect("stops the Copilot client when validation fails", () => + Effect.gen(function* () { + startupFailureClient.startImpl.mockClear(); + startupFailureClient.listModelsImpl.mockReset(); + startupFailureClient.stopImpl.mockClear(); + startupFailureClient.listModelsImpl.mockResolvedValue([ + { + id: "gpt-5", + name: "GPT-5", + capabilities: {} as ModelInfo["capabilities"], + supportedReasoningEfforts: ["low"], + }, + ]); + + const adapter = yield* CopilotAdapter; + const exit = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-validation-failure"), + runtimeMode: "full-access", + modelSelection: { + provider: "copilot", + model: "gpt-5", + options: { reasoningEffort: "xhigh" }, + }, + }) + .pipe(Effect.exit); + + assert.equal(exit._tag, "Failure"); + assert.equal(startupFailureClient.stopImpl.mock.calls.length, 1); + }), + ); + + it.effect("stops the Copilot client when session creation fails", () => + Effect.gen(function* () { + startupFailureClient.listModelsImpl.mockReset(); + startupFailureClient.createSessionImpl.mockReset(); + startupFailureClient.stopImpl.mockClear(); + startupFailureClient.listModelsImpl.mockResolvedValue([]); + startupFailureClient.createSessionImpl.mockRejectedValue(new Error("boom")); + + const adapter = yield* CopilotAdapter; + const exit = yield* adapter + .startSession({ + provider: "copilot", + threadId: asThreadId("thread-create-failure"), + runtimeMode: "full-access", + }) + .pipe(Effect.exit); + + assert.equal(exit._tag, "Failure"); + assert.equal(startupFailureClient.stopImpl.mock.calls.length, 1); + }), + ); +}); + afterAll(() => { void modeSession.destroy(); void modeClient.stop(); @@ -333,4 +402,6 @@ afterAll(() => { void planClient.stop(); void mcpSession.destroy(); void mcpClient.stop(); + void startupFailureSession.destroy(); + void startupFailureClient.stop(); }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index a0e46d92..08d0ef35 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1295,6 +1295,24 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => sessions.delete(record.threadId); }; + const stopClientAfterFailedStart = (client: CopilotClientHandle, threadId: ThreadId) => + Effect.tryPromise({ + try: async () => { + await client.stop(); + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to stop GitHub Copilot client after startup failure."), + cause, + }), + }).pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to stop GitHub Copilot client after startup failure."), + ), + ); + const startSession: CopilotAdapterShape["startSession"] = (input) => Effect.gen(function* () { const copilotSettings = yield* serverSettings.getSettings.pipe( @@ -1371,16 +1389,29 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => pendingUserInputResolvers, ); - yield* validateSessionConfiguration({ - client, - threadId: input.threadId, - ...sessionConfiguration, - }); + return yield* Effect.gen(function* () { + yield* validateSessionConfiguration({ + client, + threadId: input.threadId, + ...sessionConfiguration, + }); - const session = yield* Effect.tryPromise({ - try: async () => { - if (resumeSessionId) { - return client.resumeSession(resumeSessionId, { + const session = yield* Effect.tryPromise({ + try: async () => { + if (resumeSessionId) { + return client.resumeSession(resumeSessionId, { + ...handlers, + ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), + ...(sessionConfiguration.reasoningEffort + ? { reasoningEffort: sessionConfiguration.reasoningEffort } + : {}), + ...(input.cwd ? { workingDirectory: input.cwd } : {}), + ...(configDir ? { configDir } : {}), + ...(mcpServers ? { mcpServers } : {}), + streaming: true, + }); + } + return client.createSession({ ...handlers, ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), ...(sessionConfiguration.reasoningEffort @@ -1391,84 +1422,73 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => ...(mcpServers ? { mcpServers } : {}), streaming: true, }); - } - return client.createSession({ - ...handlers, - ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), - ...(sessionConfiguration.reasoningEffort - ? { reasoningEffort: sessionConfiguration.reasoningEffort } - : {}), - ...(input.cwd ? { workingDirectory: input.cwd } : {}), - ...(configDir ? { configDir } : {}), - ...(mcpServers ? { mcpServers } : {}), - streaming: true, - }); - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: toMessage(cause, "Failed to start GitHub Copilot session."), - cause, - }), - }); - - const record = createSessionRecord({ - threadId: input.threadId, - client, - session, - runtimeMode: input.runtimeMode, - pendingApprovalResolvers, - pendingUserInputResolvers, - cwd: input.cwd, - configDir, - ...sessionConfiguration, - }); - const unsubscribe = session.on((event) => { - handleSessionEvent(record, event); - }); - record.unsubscribe = unsubscribe; - sessionRecord = record; - sessions.set(input.threadId, record); - - yield* Queue.offerAll(runtimeEventQueue, [ - makeSyntheticEvent(input.threadId, "session.started", { - message: resumeSessionId - ? "Resumed GitHub Copilot session" - : "Started GitHub Copilot session", - resume: { sessionId: session.sessionId }, - }), - makeSyntheticEvent(input.threadId, "session.configured", { - config: { - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), - ...(sessionConfiguration.reasoningEffort - ? { reasoningEffort: sessionConfiguration.reasoningEffort } - : {}), - ...(configDir ? { configDir } : {}), - streaming: true, }, - }), - makeSyntheticEvent(input.threadId, "thread.started", { - providerThreadId: session.sessionId, - }), - makeSyntheticEvent(input.threadId, "session.state.changed", { - state: "ready", - reason: "session.started", - }), - ]); + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start GitHub Copilot session."), + cause, + }), + }); - return { - provider: PROVIDER, - status: "ready", - runtimeMode: input.runtimeMode, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), - threadId: input.threadId, - resumeCursor: session.sessionId, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } satisfies ProviderSession; + const record = createSessionRecord({ + threadId: input.threadId, + client, + session, + runtimeMode: input.runtimeMode, + pendingApprovalResolvers, + pendingUserInputResolvers, + cwd: input.cwd, + configDir, + ...sessionConfiguration, + }); + const unsubscribe = session.on((event) => { + handleSessionEvent(record, event); + }); + record.unsubscribe = unsubscribe; + sessionRecord = record; + sessions.set(input.threadId, record); + + yield* Queue.offerAll(runtimeEventQueue, [ + makeSyntheticEvent(input.threadId, "session.started", { + message: resumeSessionId + ? "Resumed GitHub Copilot session" + : "Started GitHub Copilot session", + resume: { sessionId: session.sessionId }, + }), + makeSyntheticEvent(input.threadId, "session.configured", { + config: { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), + ...(sessionConfiguration.reasoningEffort + ? { reasoningEffort: sessionConfiguration.reasoningEffort } + : {}), + ...(configDir ? { configDir } : {}), + streaming: true, + }, + }), + makeSyntheticEvent(input.threadId, "thread.started", { + providerThreadId: session.sessionId, + }), + makeSyntheticEvent(input.threadId, "session.state.changed", { + state: "ready", + reason: "session.started", + }), + ]); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(sessionConfiguration.model ? { model: sessionConfiguration.model } : {}), + threadId: input.threadId, + resumeCursor: session.sessionId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } satisfies ProviderSession; + }).pipe(Effect.onError(() => stopClientAfterFailedStart(client, input.threadId))); }); const sendTurn: CopilotAdapterShape["sendTurn"] = (input) => 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 00000000..e6bbd756 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,1174 @@ +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 { 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.ts"); +const bunExe = "bun"; + +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 ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .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"); + 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(bunExe)} ${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); +} + +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()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-adapter-test-", + }), + ), + 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.make("cursor-mock-thread"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).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", + "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") { + assert.deepStrictEqual(planUpdate.payload.plan, [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ]); + } + + yield* adapter.stopSession(threadId); + }), + ); + + 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( + "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; + const result = yield* adapter + .startSession({ + threadId: ThreadId.make("bad-provider"), + provider: "codex", + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + 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.make("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 + .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, + "mock-session-1", + ); + assert.include( + ["architect", "plan"], + String( + (modeRequest?.params as Record | undefined)?.modeId ?? + (modeRequest?.params as Record | undefined)?.value, + ), + ); + }), + ); + + 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", + () => + 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.make("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.make(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"); + // ACP updates can arrive either as distinct pending + in-progress events + // or as a single coalesced in-progress update before approval resolves. + assert.isAtLeast(toolUpdates.length, 1); + 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" && 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"); + } + + 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"); + } + }); + + 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.make("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); + + 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); + yield* Fiber.interrupt(runtimeEventsFiber); + + 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( + turnEvents.map((event) => event.type), + ["item.updated", "item.completed", "content.delta", "turn.completed"], + ); + + 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", + ); + assert.isDefined(permissionResponse); + + yield* adapter.stopSession(threadId); + }), + ); + + 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.make("cursor-assistant-tool-segmentation"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_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 === "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 { + 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), + ); + 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", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("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: "approval-required", + 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("stopping a session settles pending approval waits", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("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: "approval-required", + 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.make("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("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; + const serverSettings = yield* ServerSettingsService; + const threadId = ThreadId.make("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"); + 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.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); + assert.deepStrictEqual(argvRuns[0], ["acp"]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const setConfigRequests = requests.filter( + (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"); + + 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); + }), + ); + + 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/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 00000000..b09e0356 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1057 @@ +/** + * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. + * + * @module CursorAdapterLive + */ +import * as nodePath from "node:path"; + +import { + ApprovalRequestId, + type CursorModelOptions, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + DateTime, + Deferred, + Effect, + Exit, + 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"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; +import { + CursorAskQuestionRequest, + CursorCreatePlanRequest, + CursorUpdateTodosRequest, + extractAskQuestions, + extractPlanMarkdown, + extractTodosAsPlan, +} from "../acp/CursorAcpExtension.ts"; +import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { resolveCursorAcpBaseModelId } 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"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +export interface CursorAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface CursorSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + 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 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 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); +} + +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 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 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 { + 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; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + 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 managedNativeEventLogger = + 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); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + 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, + 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 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 }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: CursorSessionContext) => + 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); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: CursorAdapterShape["startSession"] = (input) => + 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 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 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; + }), + ); + 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", + ); + 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(), + 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), + ), + ); + + 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, + 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.getEvents(), (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; + + 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), + ); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + const turnModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveCursorAcpBaseModelId(model); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + 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; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + + 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: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "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.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.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* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + 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) => + 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 }))); + + 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(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies CursorAdapterShape; + }); +} + +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/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts new file mode 100644 index 00000000..be90e3c8 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -0,0 +1,687 @@ +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 { 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, + parseCursorAboutOutput, + parseCursorCliConfigChannel, + parseCursorVersionDate, + resolveCursorAcpBaseModelId, + 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; +} + +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", + currentValue: "gpt-5.4-medium-fast", + options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }], + 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-4.6-opus-high-thinking", + options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }], + 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; + +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", + 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; + +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( + getCursorFallbackModels({ + customModels: ["internal/cursor-model"], + }).map((model) => model.slug), + ).toEqual(["internal/cursor-model"]); + }); +}); + +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({ + 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: [], + }); + }); + + 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", () => { + 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("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", + ]); + }); + + 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", () => { + 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); + 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 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"); + }); +}); + +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 }]); + }); + + it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { + expect( + resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { + fastMode: false, + }), + ).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 new file mode 100644 index 00000000..70d5656b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -0,0 +1,1136 @@ +import * as nodeFs from "node:fs"; +import * as nodeOs from "node:os"; +import * as nodePath from "node:path"; + +import type { + CursorModelOptions, + CursorSettings, + ModelCapabilities, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, + ServerSettingsError, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { CursorProvider } from "../Services/CursorProvider.ts"; +import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const PROVIDER = "cursor" as const; +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +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 = { + _meta: { + parameterizedModelPicker: true, + }, +} 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; +} + +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 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": + case "extra high": + return "xhigh"; + default: + return undefined; + } +} + +function findCursorModelConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + 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(); + 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 = findCursorEffortConfigOption(configOptions); + const reasoningEffortLevels = + reasoningConfig?.type === "select" + ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { + const normalizedValue = normalizeCursorReasoningValue(entry.value); + if (!normalizedValue) { + return []; + } + return [ + { + value: normalizedValue, + label: entry.name, + ...(normalizeCursorReasoningValue(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 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 { + 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)); + }); + +const withCursorAcpProbeRuntime = ( + cursorSettings: CursorSettings, + useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, +) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped); + +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 trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "default"; + return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; +} + +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 = findCursorEffortConfigOption(configOptions); + const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); + if (reasoningOption && requestedReasoning) { + const value = findCursorSelectOptionValue(reasoningOption, (option) => { + const normalizedValue = normalizeCursorReasoningValue(option.value); + const normalizedName = normalizeCursorReasoningValue(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 && typeof modelOptions?.fastMode === "boolean") { + const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); + 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; +} + +export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => + withCursorAcpProbeRuntime(cursorSettings, (acp) => + Effect.map(acp.start(), (started) => + buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []), + ), + ); + +export const discoverCursorModelCapabilitiesViaAcp = ( + cursorSettings: CursorSettings, + existingModels: ReadonlyArray, +) => + 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 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; + yield* Effect.annotateCurrentSpan({ + "cursor.acp.model.value": modelSlug, + "cursor.acp.model.currentValue": probeCurrentModelValue, + "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, + }); + 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.retry({ times: 3 }), + Effect.withSpan("cursor-acp-model-capability-probe"), + Effect.catchCause((cause) => + Effect.logWarning("Cursor ACP capability probe failed", { + modelSlug, + cause: Cause.pretty(cause), + }), + ), + ); + }, + { 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.withSpan("cursor-acp-model-capability-discovery", {})), + ); + +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. */ +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 auth: ServerProviderAuth; + 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; + 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) { + 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 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"); + 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. + * + * 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 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(); + + // 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", + auth: { status: "unknown" }, + message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.", + }; + } + + const plain = stripAnsi(combined); + 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", auth: { status: "unknown" } }; + } + 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.", + }; + } + + // Any non-empty email value means authenticated. + return { version, status: "ready", auth: { status: "authenticated" } }; +} + +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); + +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, + 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.", + }, + }); + } + + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorAboutCommand.pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + 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)}.`, + }, + }); + } + + 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`.", + }, + }); + } + + 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>(); + let discoveryWarning: string | undefined; + if (parsed.auth.status !== "unauthenticated") { + 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; + } + } + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed, + discoveredModels: Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => [] as const, + ), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); + }, +); + +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), + 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.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, + }); + }), +); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts new file mode 100644 index 00000000..0bb1a571 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -0,0 +1,487 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ThreadId } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { + appendOpenCodeAssistantTextDelta, + makeOpenCodeAdapterLive, + mergeOpenCodeAssistantText, +} from "./OpenCodeAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +const runtimeMock = vi.hoisted(() => { + type MessageEntry = { + info: { + id: string; + role: "user" | "assistant"; + }; + parts: Array; + }; + + const state = { + startCalls: [] as string[], + sessionCreateUrls: [] as string[], + authHeaders: [] as Array, + abortCalls: [] as string[], + closeCalls: [] as string[], + revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + promptAsyncError: null as Error | null, + closeError: null as Error | null, + messages: [] as MessageEntry[], + subscribedEvents: [] as unknown[], + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.sessionCreateUrls.length = 0; + state.authHeaders.length = 0; + state.abortCalls.length = 0; + state.closeCalls.length = 0; + state.revertCalls.length = 0; + state.promptAsyncError = null; + state.closeError = null; + state.messages = []; + state.subscribedEvents = []; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + runtimeMock.state.startCalls.push(binaryPath); + return { + url: "http://127.0.0.1:4301", + process: { + once() {}, + }, + close() {}, + }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() { + runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }, + })), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => { + runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return { data: { id: `${baseUrl}/session` } }; + }), + abort: vi.fn(async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.abortCalls.push(sessionID); + }), + promptAsync: vi.fn(async () => { + if (runtimeMock.state.promptAsyncError) { + throw runtimeMock.state.promptAsyncError; + } + }), + messages: vi.fn(async () => ({ data: runtimeMock.state.messages })), + revert: vi.fn( + async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { + runtimeMock.state.revertCalls.push({ + sessionID, + ...(messageID ? { messageID } : {}), + }); + if (!messageID) { + runtimeMock.state.messages = []; + return; + } + + const targetIndex = runtimeMock.state.messages.findIndex( + (entry) => entry.info.id === messageID, + ); + runtimeMock.state.messages = + targetIndex >= 0 + ? runtimeMock.state.messages.slice(0, targetIndex + 1) + : runtimeMock.state.messages; + }, + ), + }, + event: { + subscribe: vi.fn(async () => ({ + stream: (async function* () { + for (const event of runtimeMock.state.subscribedEvents) { + yield event; + } + })(), + })), + }, + }), + ), + }; +}); + +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + remove: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), +}); + +const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); + +it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "opencode"); + assert.equal(session.threadId, "thread-opencode"); + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + assert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ); + + it.effect("stops a configured-server session without trying to own server lifecycle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(asThreadId("thread-opencode")); + + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + + it.effect("clears session state when stopAll cleanup fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-a"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-b"), + runtimeMode: "full-access", + }); + + runtimeMock.state.closeError = new Error("close failed"); + const error = yield* adapter.stopAll().pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.equal(error.detail, "Failed to stop 2 OpenCode sessions."); + assert.deepEqual(runtimeMock.state.closeCalls, [ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + assert.deepEqual(sessions, []); + }), + ); + + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-send-turn-failure"), + runtimeMode: "full-access", + }); + + runtimeMock.state.promptAsyncError = new Error("prompt failed"); + const error = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-send-turn-failure"), + input: "Fix it", + modelSelection: { + provider: "opencode", + model: "openai/gpt-5", + }, + }) + .pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag !== "ProviderAdapterRequestError") { + throw new Error("Unexpected error type"); + } + assert.equal(error.detail, "prompt failed"); + assert.equal( + error.message, + "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + ); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.status, "ready"); + assert.equal(sessions[0]?.activeTurnId, undefined); + assert.equal(sessions[0]?.lastError, "prompt failed"); + }), + ); + + it.effect("reverts the full thread when rollback removes every assistant turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-rollback-all"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.messages = [ + { + info: { id: "assistant-1", role: "assistant" }, + parts: [], + }, + { + info: { id: "assistant-2", role: "assistant" }, + parts: [], + }, + ]; + + const snapshot = yield* adapter.rollbackThread(threadId, 2); + + assert.deepEqual(runtimeMock.state.revertCalls, [ + { sessionID: "http://127.0.0.1:9999/session" }, + ]); + assert.deepEqual(snapshot.turns, []); + }), + ); + + it.effect("deduplicates overlapping assistant text deltas after part updates", () => + Effect.sync(() => { + const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); + const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); + const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!"); + + assert.deepEqual( + [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], + ["Hello", " world", "!"], + ); + assert.equal(secondUpdate.latestText, "Hello world!"); + }), + ); + + it.effect("writes provider-native observability records using the session thread id", () => + Effect.gen(function* () { + const nativeEvents: Array<{ + readonly event?: { + readonly provider?: string; + readonly threadId?: string; + readonly providerThreadId?: string; + readonly type?: string; + }; + }> = []; + const nativeThreadIds: Array = []; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + info: { + id: "msg-missing-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/other-session", + info: { + id: "msg-other-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: (event: unknown, threadId: ThreadId | null) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + nativeThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const started = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return started; + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(session.threadId, "thread-native-log"); + assert.equal(nativeEvents.length, 1); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "opencode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", + ), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.type === "message.updated"), + true, + ); + assert.equal( + nativeThreadIds.every((threadId) => threadId === "thread-native-log"), + true, + ); + }), + ); + + it.effect("keeps the event pump alive when native event logging fails", () => + Effect.gen(function* () { + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log-failure", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: () => Effect.die(new Error("native log write failed")), + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const sessions = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log-failure"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return yield* adapter.listSessions(); + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + assert.deepEqual(runtimeMock.state.closeCalls, []); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 00000000..4e3c12ef --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,1344 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { Cause, Effect, Layer, Queue, Stream } from "effect"; +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + buildOpenCodePermissionRules, + connectToOpenCodeServer, + createOpenCodeSdkClient, + openCodeQuestionId, + parseOpenCodeModelSlug, + toOpenCodeFileParts, + toOpenCodePermissionReply, + toOpenCodeQuestionAnswers, + type OpenCodeServerConnection, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface OpenCodeSessionContext { + session: ProviderSession; + readonly client: OpencodeClient; + readonly server: OpenCodeServerConnection; + readonly directory: string; + readonly openCodeSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly messageRoleById: Map; + readonly partById: Map; + readonly emittedTextByPartId: Map; + readonly completedAssistantPartIds: Set; + readonly turns: Array; + activeTurnId: TurnId | undefined; + activeAgent: string | undefined; + activeVariant: string | undefined; + stopped: boolean; + readonly eventsAbortController: AbortController; +} + +export interface OpenCodeAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProviderAdapterRequestError" + ); +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" +> { + return { + eventId: EventId.make(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + }; +} + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if ( + normalized.includes("task") || + normalized.includes("agent") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function mapPermissionToRequestType( + permission: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (permission) { + case "bash": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +function resolveTurnSnapshot( + context: OpenCodeSessionContext, + turnId: TurnId, +): OpenCodeTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + resolveTurnSnapshot(context, turnId).items.push(item); +} + +function ensureSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): OpenCodeSessionContext { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (session.stopped) { + throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return session; +} + +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { + return request.questions.map((question, index) => ({ + id: openCodeQuestionId(index, question), + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ...(question.multiple ? { multiSelect: true } : {}), + })); +} + +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; +} + +function textFromPart(part: Part): string | undefined { + switch (part.type) { + case "text": + case "reasoning": + return part.text; + default: + return undefined; + } +} + +function commonPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +function suffixPrefixOverlap(text: string, delta: string): number { + const maxLength = Math.min(text.length, delta.length); + for (let length = maxLength; length > 0; length -= 1) { + if (text.endsWith(delta.slice(0, length))) { + return length; + } + } + return 0; +} + +function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { + if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { + return previousText; + } + return nextText; +} + +export function mergeOpenCodeAssistantText( + previousText: string | undefined, + nextText: string, +): { + readonly latestText: string; + readonly deltaToEmit: string; +} { + const latestText = resolveLatestAssistantText(previousText, nextText); + return { + latestText, + deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + }; +} + +export function appendOpenCodeAssistantTextDelta( + previousText: string, + delta: string, +): { + readonly nextText: string; + readonly deltaToEmit: string; +} { + const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta)); + return { + nextText: previousText + deltaToEmit, + deltaToEmit, + }; +} + +function isoFromEpochMs(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return new Date(value).toISOString(); +} + +function messageRoleForPart( + context: OpenCodeSessionContext, + part: Pick, +): "assistant" | "user" | undefined { + const known = context.messageRoleById.get(part.messageID); + if (known) { + return known; + } + return part.type === "tool" ? "assistant" : undefined; +} + +function detailFromToolPart(part: Extract): string | undefined { + switch (part.state.status) { + case "completed": + return part.state.output; + case "error": + return part.state.error; + case "running": + return part.state.title; + default: + return undefined; + } +} + +function toolStateCreatedAt(part: Extract): string | undefined { + switch (part.state.status) { + case "running": + return isoFromEpochMs(part.state.time.start); + case "completed": + case "error": + return isoFromEpochMs(part.state.time.end); + default: + return undefined; + } +} + +function sessionErrorMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return "OpenCode session failed."; + } + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = data && "message" in data ? data.message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : "OpenCode session failed."; +} + +function updateProviderSession( + context: OpenCodeSessionContext, + patch: Partial, + options?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt: nowIso(), + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise { + context.stopped = true; + context.eventsAbortController.abort(); + try { + await context.client.session + .abort({ sessionID: context.openCodeSessionId }) + .catch(() => undefined); + } catch {} + context.server.close(); +} + +export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { + return Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const services = yield* Effect.context(); + const nativeEventLogger = + _options?.nativeEventLogger ?? + (_options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitPromise = (event: ProviderRuntimeEvent) => + emit(event).pipe(Effect.runPromiseWith(services)); + const writeNativeEventPromise = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => + (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe( + Effect.runPromiseWith(services), + ); + const writeNativeEventBestEffort = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => writeNativeEventPromise(threadId, event).catch(() => undefined); + + const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { + if (context.stopped) { + return; + } + context.stopped = true; + sessions.delete(context.session.threadId); + context.server.close(); + const turnId = context.activeTurnId; + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }).catch(() => undefined); + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }).catch(() => undefined); + }; + + /** Emit content.delta and item.completed events for an assistant text part. */ + const emitAssistantTextDelta = async ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ): Promise => { + const text = textFromPart(part); + if (text === undefined) { + return; + } + const previousText = context.emittedTextByPartId.get(part.id); + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); + context.emittedTextByPartId.set(part.id, latestText); + if (latestText !== text) { + context.partById.set( + part.id, + (part.type === "text" || part.type === "reasoning" + ? { ...part, text: latestText } + : part) satisfies Part, + ); + } + if (deltaToEmit.length > 0) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw, + }), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: deltaToEmit, + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(latestText.length > 0 ? { detail: latestText } : {}), + }, + }); + } + }; + + const startEventPump = (context: OpenCodeSessionContext) => { + void (async () => { + try { + const subscription = await context.client.event.subscribe(undefined, { + signal: context.eventsAbortController.signal, + }); + + for await (const event of subscription.stream) { + const payloadSessionId = + "properties" in event + ? (event.properties as { sessionID?: unknown }).sessionID + : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + continue; + } + + const turnId = context.activeTurnId; + await writeNativeEventBestEffort(context.session.threadId, { + observedAt: nowIso(), + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, + }); + + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + await emitAssistantTextDelta(context, part, turnId, event); + } + } + break; + } + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta( + previousText, + delta, + ); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + }), + type: "content.delta", + payload: { + streamKind, + delta: deltaToEmit, + }, + }); + break; + } + + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + if (messageRole === "assistant") { + await emitAssistantTextDelta(context, part, turnId, event); + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + await emitPromise(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } + + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } + + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } + + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } + + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } + + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { status: "running", activeTurnId: turnId }); + } + + if (event.properties.status.type === "retry") { + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; + } + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + ); + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + }), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, raw: event }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } + + default: + break; + } + } + } catch (error) { + if (context.eventsAbortController.signal.aborted || context.stopped) { + return; + } + emitUnexpectedExit( + context, + error instanceof Error ? error.message : "OpenCode event stream failed.", + ); + } + })(); + + context.server.process?.once("exit", (code, signal) => { + if (context.stopped) { + return; + } + emitUnexpectedExit( + context, + `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, + ); + }); + }; + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to read OpenCode settings.", + cause, + }), + ), + ); + const binaryPath = settings.providers.opencode.binaryPath; + const serverUrl = settings.providers.opencode.serverUrl; + const serverPassword = settings.providers.opencode.serverPassword; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(existing), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to stop existing OpenCode session.", + cause, + }), + }); + sessions.delete(input.threadId); + } + + const started = yield* Effect.tryPromise({ + try: async () => { + const server = await connectToOpenCodeServer({ binaryPath, serverUrl }); + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = await client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }); + if (!openCodeSession.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + return { server, client, openCodeSession: openCodeSession.data }; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + cause, + }), + }); + + // Guard against a concurrent startSession call that may have raced + // and already inserted a session while we were awaiting async work. + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another call won the race – clean up the session we just created + // (including the remote SDK session) and return the existing one. + yield* Effect.tryPromise({ + try: () => + started.client.session + .abort({ sessionID: started.openCodeSession.id }) + .catch(() => undefined), + catch: () => undefined, + }).pipe(Effect.ignore); + started.server.close(); + return raceWinner.session; + } + + const createdAt = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: false, + eventsAbortController: new AbortController(), + }; + sessions.set(input.threadId, context); + startEventPump(context); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "session.started", + payload: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, + }, + }); + + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { provider: PROVIDER, model: context.session.model } + : undefined); + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.agent + : undefined; + const variant = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.variant + : undefined; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + + const promptExit = yield* Effect.exit( + Effect.tryPromise({ + try: async () => { + await context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", + cause, + }), + }), + ); + if (promptExit._tag === "Failure") { + const failure = Cause.squash(promptExit.cause); + const requestError = isProviderAdapterRequestError(failure) + ? failure + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: + failure instanceof Error ? failure.message : "Failed to send OpenCode turn.", + cause: failure, + }); + const failureMessage = requestError.detail; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: failureMessage, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.aborted", + payload: { + reason: failureMessage, + }, + }); + return yield* requestError; + } + + return { + threadId: input.threadId, + turnId, + }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", + cause, + }), + }); + if (turnId ?? context.activeTurnId) { + yield* emit({ + ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: + cause instanceof Error + ? cause.message + : "Failed to submit OpenCode permission reply.", + cause, + }), + }); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", + cause, + }), + }); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(context), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", + cause, + }), + }); + sessions.delete(threadId); + yield* emit({ + ...buildEventBase({ threadId }), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", + cause, + }), + }); + + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })); + + return { + threadId, + turns, + }; + }, + ); + + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: + cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", + cause, + }), + }); + + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const targetIndex = assistantMessages.length - numTurns - 1; + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; + yield* Effect.tryPromise({ + try: () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.revert", + detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", + cause, + }), + }); + + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + const contexts = [...sessions.values()]; + sessions.clear(); + const results = await Promise.allSettled( + contexts.map((context) => stopOpenCodeContext(context)), + ); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError( + errors, + `Failed to stop ${errors.length} OpenCode sessions.`, + ); + } + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "*", + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }), + ); +} + +export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts new file mode 100644 index 00000000..cf3d588d --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + runVersionError: null as Error | null, + inventoryError: null as Error | null, + }; + + return { + state, + reset() { + state.runVersionError = null; + state.inventoryError = null; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + runOpenCodeCommand: vi.fn(async () => { + if (runtimeMock.state.runVersionError) { + throw runtimeMock.state.runVersionError; + } + return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() {}, + })), + createOpenCodeSdkClient: vi.fn(() => ({})), + loadOpenCodeInventory: vi.fn(async () => { + if (runtimeMock.state.inventoryError) { + throw runtimeMock.state.inventoryError; + } + return { + providerList: { connected: [], all: [] }, + agents: [], + }; + }), + flattenOpenCodeModels: vi.fn(() => []), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const makeTestLayer = (settingsOverrides?: Parameters[0]) => + makeOpenCodeProviderLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), + Layer.provideMerge(NodeServices.layer), + ); + +it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); +}); + +it.layer( + makeTestLayer({ + providers: { + opencode: { + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), +)("OpenCodeProviderLive with configured server URL", (it) => { + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("401 Unauthorized"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 00000000..f1969412 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,342 @@ +import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; +import { Cause, Effect, Equal, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, +} from "../providerSnapshot.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { + connectToOpenCodeServer, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + createOpenCodeSdkClient, + flattenOpenCodeModels, + loadOpenCodeInventory, + runOpenCodeCommand, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +class OpenCodeProbePromiseError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + this.name = "OpenCodeProbePromiseError"; + } +} + +function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError { + return new OpenCodeProbePromiseError(cause); +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (!(cause instanceof Error)) { + return undefined; + } + + const message = cause.message.trim(); + if (message.length === 0) { + return undefined; + } + if ( + message === "An error occurred in Effect.tryPromise" || + message === "An error occurred in Effect.try" + ) { + return undefined; + } + return message; +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : ""; + const detail = normalizedErrorMessage(input.cause); + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (input.cause instanceof Error && isCommandMissingCause(input.cause)) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + return { + installed: true, + message: detail + ? `Failed to execute OpenCode CLI health check: ${detail}` + : "Failed to execute OpenCode CLI health check.", + }; +} + +const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + [], + PROVIDER, + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); +}; + +export function checkOpenCodeProviderStatus(input: { + readonly settings: OpenCodeSettings; + readonly cwd: string; +}): Effect.Effect { + const checkedAt = new Date().toISOString(); + const customModels = input.settings.customModels; + const isExternalServer = input.settings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: input.settings.serverUrl, + }); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + return Effect.gen(function* () { + if (!input.settings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + Effect.tryPromise({ + try: () => + runOpenCodeCommand({ + binaryPath: input.settings.binaryPath, + args: ["--version"], + }), + catch: toOpenCodeProbeError, + }), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + } + + const inventoryExit = yield* Effect.exit( + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + connectToOpenCodeServer({ + binaryPath: input.settings.binaryPath, + serverUrl: input.settings.serverUrl, + }), + catch: toOpenCodeProbeError, + }), + (server) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(isExternalServer && input.settings.serverPassword + ? { serverPassword: input.settings.serverPassword } + : {}), + }); + return await loadOpenCodeInventory(client); + }, + catch: toOpenCodeProbeError, + }), + (server) => Effect.sync(() => server.close()), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); + }); +} + +export function makeOpenCodeProviderLive() { + return Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + + const getProviderSettings = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + + return yield* makeManagedServerProvider({ + getSettings: getProviderSettings.pipe(Effect.orDie), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingOpenCodeProvider, + checkProvider: getProviderSettings.pipe( + Effect.flatMap((settings) => + checkOpenCodeProviderStatus({ + settings, + cwd: serverConfig.cwd, + }), + ), + ), + }); + }), + ); +} + +export const OpenCodeProviderLive = makeOpenCodeProviderLive(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 8937b208..1be73933 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -10,6 +10,10 @@ import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import type { CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -66,6 +70,40 @@ const fakeCopilotAdapter: CopilotAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: "opencode", + 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 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( @@ -74,6 +112,8 @@ const layer = it.layer( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(CopilotAdapter, fakeCopilotAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), NodeServices.layer, @@ -87,12 +127,16 @@ layer("ProviderAdapterRegistryLive", (it) => { const codex = yield* registry.getByProvider("codex"); const copilot = yield* registry.getByProvider("copilot"); const claude = yield* registry.getByProvider("claudeAgent"); + const openCode = yield* registry.getByProvider("opencode"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(copilot, fakeCopilotAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(openCode, fakeOpenCodeAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "copilot", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "copilot", "claudeAgent", "opencode", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 6b48da4e..3202b102 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -18,6 +18,8 @@ import { import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -26,10 +28,17 @@ export interface ProviderAdapterRegistryLiveOptions { const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( options?: ProviderAdapterRegistryLiveOptions, ) { + const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter); const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* CopilotAdapter, yield* ClaudeAdapter]; + : [ + yield* CodexAdapter, + yield* CopilotAdapter, + yield* ClaudeAdapter, + yield* OpenCodeAdapter, + ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []), + ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d7c6614f..7d548726 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -30,14 +30,34 @@ import { readCodexConfigModelProvider, } from "./CodexProvider.ts"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { + haveProvidersChanged, + mergeProviderSnapshot, + ProviderRegistryLive, +} from "./ProviderRegistry.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +process.env.T3CODE_CURSOR_ENABLED = "1"; + // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const fakeOpenCodeSnapshot: ServerProvider = { + provider: "opencode", + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode test stub", +}; function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ @@ -563,10 +583,107 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); - it.effect("does not probe provider health during registry startup", () => + it("does not reintroduce models removed from the latest 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: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).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("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( @@ -596,20 +713,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); 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", @@ -630,6 +751,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( prefix: "t3-provider-registry-", }), ), + Layer.provideMerge( + Layer.succeed(OpenCodeProvider, { + getSnapshot: Effect.succeed(fakeOpenCodeSnapshot), + refresh: Effect.succeed(fakeOpenCodeSnapshot), + streamChanges: Stream.empty, + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); @@ -639,6 +767,19 @@ 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 }; + } if (joined === "login status") { return { stdout: "Logged in\n", stderr: "", code: 0 }; } @@ -646,19 +787,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); 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, @@ -690,6 +825,77 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + 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, + }, + }, + }), + ), + ); + 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", "copilot", "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", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ @@ -1036,111 +1242,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); - it.effect( - "includes Claude Opus 4.7 on Claude prerelease builds newer than the minimum stable version", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - true, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { stdout: "2.1.112-beta.1\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("normalizes custom Claude Opus 4.7 models on older Claude Code versions", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - yield* serverSettings.updateSettings({ - providers: { - claudeAgent: { - customModels: ["claude-opus-4-7", "claude-custom"], - }, - }, - }); - - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.models.filter((model) => model.slug === "claude-opus-4-6").length, - 1, - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-custom"), - true, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("gates Claude Opus 4.7 aliases from custom model settings on older versions", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - yield* serverSettings.updateSettings({ - providers: { - claudeAgent: { - customModels: ["opus", "opus-4.7", "claude-opus-4.7"], - }, - }, - }); - - const status = yield* checkClaudeProviderStatus(); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.models.filter((model) => model.slug === "claude-opus-4-6").length, - 1, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index e403abb5..99341320 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -10,12 +10,13 @@ import { ServerConfig } from "../../config.ts"; import { ClaudeProviderLive } from "./ClaudeProvider.ts"; import { CopilotProviderLive } from "./CopilotProvider.ts"; import { CodexProviderLive } from "./CodexProvider.ts"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; +import { CursorProviderLive } from "./CursorProvider.ts"; +import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; -import type { CopilotProviderShape } from "../Services/CopilotProvider.ts"; import { CopilotProvider } from "../Services/CopilotProvider.ts"; -import type { CodexProviderShape } from "../Services/CodexProvider.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; +import { CursorProvider } from "../Services/CursorProvider.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, @@ -26,36 +27,113 @@ import { writeProviderStatusCache, } from "../providerStatusCache.ts"; +type ProviderSnapshotSource = { + readonly provider: ProviderKind; + readonly getSnapshot: Effect.Effect; + readonly refresh: Effect.Effect; + readonly streamChanges: Stream.Stream; +}; + const loadProviders = ( - codexProvider: CodexProviderShape, - copilotProvider: CopilotProviderShape, - claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, copilotProvider.getSnapshot, claudeProvider.getSnapshot], { + providerSources: ReadonlyArray, +): Effect.Effect> => + Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, { 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 => { + const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); + return nextModels.map((model) => { + const previousModel = previousBySlug.get(model.slug); + if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + return model; + } + return { + ...model, + capabilities: previousModel.capabilities, + }; + }); +}; + +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, ): boolean => !Equal.equals(previousProviders, nextProviders); -export const ProviderRegistryLive = Layer.effect( +const ProviderRegistryLiveBase = Layer.effect( ProviderRegistry, Effect.gen(function* () { const codexProvider = yield* CodexProvider; const copilotProvider = yield* CopilotProvider; const claudeProvider = yield* ClaudeProvider; + const openCodeProvider = yield* OpenCodeProvider; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + + const cursorProvider = yield* CursorProvider; + + const providerSources = [ + { + provider: "codex", + getSnapshot: codexProvider.getSnapshot, + refresh: codexProvider.refresh, + streamChanges: codexProvider.streamChanges, + }, + { + provider: "copilot", + getSnapshot: copilotProvider.getSnapshot, + refresh: copilotProvider.refresh, + streamChanges: copilotProvider.streamChanges, + }, + { + provider: "claudeAgent", + getSnapshot: claudeProvider.getSnapshot, + refresh: claudeProvider.refresh, + streamChanges: claudeProvider.streamChanges, + }, + { + provider: "opencode", + getSnapshot: openCodeProvider.getSnapshot, + refresh: openCodeProvider.refresh, + streamChanges: openCodeProvider.streamChanges, + }, + { + provider: "cursor", + getSnapshot: cursorProvider.getSnapshot, + refresh: cursorProvider.refresh, + streamChanges: cursorProvider.streamChanges, + }, + ] satisfies ReadonlyArray; + const activeProviders = PROVIDER_CACHE_IDS; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(codexProvider, copilotProvider, claudeProvider); + const fallbackProviders = yield* loadProviders(providerSources); const cachePathByProvider = new Map( - PROVIDER_CACHE_IDS.map( + activeProviders.map( (provider) => [ provider, @@ -69,8 +147,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)!; @@ -121,7 +200,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()]); @@ -152,49 +234,40 @@ 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 "copilot": - return yield* copilotProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - case "claudeAgent": - return yield* claudeProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ); - default: - return yield* Effect.all( - [ - codexProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ), - copilotProvider.refresh.pipe( - Effect.flatMap((nextProvider) => syncProvider(nextProvider)), - ), - claudeProvider.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(copilotProvider.streamChanges, (provider) => - syncProvider(provider), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => - syncProvider(provider), - ).pipe(Effect.forkScoped); + 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 })), + ); return { getProviders: Ref.get(providersRef), @@ -208,8 +281,16 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe( - Layer.provideMerge(CodexProviderLive), - Layer.provideMerge(CopilotProviderLive), - Layer.provideMerge(ClaudeProviderLive), +); + +export const ProviderRegistryLive = Layer.unwrap( + Effect.sync(() => + ProviderRegistryLiveBase.pipe( + Layer.provideMerge(CursorProviderLive), + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(CopilotProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(OpenCodeProviderLive), + ), + ), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1f7fb14c..45199a02 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -45,7 +45,6 @@ const unsupported = () => Effect.die(new Error("Unsupported provider call in tes function makeReadModel( threads: ReadonlyArray<{ readonly id: ThreadId; - readonly latestTurnCompletedAt?: string | null; readonly session: { readonly threadId: ThreadId; readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; @@ -87,16 +86,7 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, - latestTurn: thread.latestTurnCompletedAt - ? { - turnId: TurnId.make(`${thread.id}-latest-turn`), - state: "completed" as const, - requestedAt: thread.latestTurnCompletedAt, - startedAt: thread.latestTurnCompletedAt, - completedAt: thread.latestTurnCompletedAt, - assistantMessageId: null, - } - : null, + latestTurn: null, messages: [], session: thread.session, activities: [], @@ -228,51 +218,6 @@ describe("ProviderSessionReaper", () => { expect(harness.stoppedThreadIds.has(threadId)).toBe(true); }); - it("does not reap a session immediately after a long turn finishes", async () => { - const threadId = ThreadId.make("thread-reaper-long-turn-complete"); - const completedAt = new Date(Date.now() - 100).toISOString(); - const harness = await createHarness({ - readModel: makeReadModel([ - { - id: threadId, - latestTurnCompletedAt: completedAt, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-04-14T00:00:00.000Z", - }, - }, - ]), - }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); - - await runtime!.runPromise( - repository.upsert({ - threadId, - providerName: "claudeAgent", - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-04-14T00:00:00.000Z", - resumeCursor: { - opaque: "resume-long-turn", - }, - runtimePayload: null, - }), - ); - - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(harness.stopSession).not.toHaveBeenCalled(); - }); - it("skips stale sessions when the thread still has an active turn", async () => { const threadId = ThreadId.make("thread-reaper-active-turn"); const turnId = TurnId.make("turn-reaper-active"); @@ -574,71 +519,4 @@ describe("ProviderSessionReaper", () => { reapedThreadId, ]); }); - - it("skips reaping when the binding was refreshed after the sweep snapshot", async () => { - const threadId = ThreadId.make("thread-reaper-replacement-race"); - const now = new Date().toISOString(); - const harness = await createHarness({ - readModel: makeReadModel([ - { - id: threadId, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }, - ]), - stopSessionImplementation: () => - Effect.fail( - new ProviderValidationError({ - operation: "ProviderSessionReaper.test", - issue: "should not stop refreshed session", - }), - ), - }); - const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); - - await runtime!.runPromise( - repository.upsert({ - threadId, - providerName: "codex", - adapterKey: "codex", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-04-14T00:00:00.000Z", - resumeCursor: { - opaque: "resume-race-old", - }, - runtimePayload: null, - }), - ); - - const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); - - await runtime!.runPromise( - repository.upsert({ - threadId, - providerName: "claudeAgent", - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: new Date().toISOString(), - resumeCursor: { - opaque: "resume-race-new", - }, - runtimePayload: null, - }), - ); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(harness.stopSession).not.toHaveBeenCalled(); - }); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 4803e45c..aa31c8c7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -40,14 +40,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } - const thread = threadsById.get(binding.threadId); - const lastSeenMs = Math.max( - ...[binding.lastSeenAt, thread?.latestTurn?.completedAt] - .flatMap((value) => - typeof value === "string" && value.length > 0 ? [Date.parse(value)] : [], - ) - .filter(Number.isFinite), - ); + const lastSeenMs = Date.parse(binding.lastSeenAt); if (Number.isNaN(lastSeenMs)) { yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { threadId: binding.threadId, @@ -62,6 +55,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } + const thread = threadsById.get(binding.threadId); if (thread?.session?.activeTurnId != null) { yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { threadId: binding.threadId, @@ -71,26 +65,6 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = continue; } - const currentBinding = (yield* directory.listBindings()).find( - (candidate) => candidate.threadId === binding.threadId, - ); - if ( - !currentBinding || - currentBinding.provider !== binding.provider || - currentBinding.lastSeenAt !== binding.lastSeenAt || - currentBinding.status === "stopped" - ) { - yield* Effect.logDebug("provider.session.reaper.skipped-updated-binding", { - threadId: binding.threadId, - provider: binding.provider, - lastSeenAt: binding.lastSeenAt, - currentProvider: currentBinding?.provider ?? null, - currentLastSeenAt: currentBinding?.lastSeenAt ?? null, - currentStatus: currentBinding?.status ?? null, - }); - continue; - } - const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( Effect.tap(() => Effect.logInfo("provider.session.reaped", { diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 00000000..f1edb316 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,12 @@ +import { Context } 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 Context.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/provider/Services/CursorProvider.ts b/apps/server/src/provider/Services/CursorProvider.ts new file mode 100644 index 00000000..aa70994f --- /dev/null +++ b/apps/server/src/provider/Services/CursorProvider.ts @@ -0,0 +1,9 @@ +import { Context } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider.ts"; + +export interface CursorProviderShape extends ServerProviderShape {} + +export class CursorProvider extends Context.Service()( + "t3/provider/Services/CursorProvider", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 00000000..ad566002 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,12 @@ +import { Context } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "opencode"; +} + +export class OpenCodeAdapter extends Context.Service()( + "t3/provider/Services/OpenCodeAdapter", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts new file mode 100644 index 00000000..a799830e --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeProvider.ts @@ -0,0 +1,9 @@ +import { Context } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider.ts"; + +export interface OpenCodeProviderShape extends ServerProviderShape {} + +export class OpenCodeProvider extends Context.Service()( + "t3/provider/Services/OpenCodeProvider", +) {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 38a05f75..91155764 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/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts new file mode 100644 index 00000000..7457713e --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { acpPermissionOutcome, mapAcpToAdapterError } 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("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", + }), + ); + + 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 new file mode 100644 index 00000000..914bb7e8 --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -0,0 +1,54 @@ +import { + type ProviderApprovalDecision, + type ProviderKind, + type ThreadId, +} from "@t3tools/contracts"; +import { Schema } from "effect"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + type ProviderAdapterError, +} from "../Errors.ts"; + +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": + default: + return "reject-once"; + } +} 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 00000000..79b51f58 --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -0,0 +1,155 @@ +import { RuntimeRequestId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + makeAcpAssistantItemEvent, + 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.make("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.make("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.make("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.make("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, + 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 new file mode 100644 index 00000000..0c0f06cc --- /dev/null +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -0,0 +1,242 @@ +import { + type RuntimeEventRawSource, + 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"; + +type AcpAdapterRawSource = Extract< + RuntimeEventRawSource, + "acp.jsonrpc" | `acp.${string}.extension` +>; + +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.make(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 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.make(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 { + return { + type: "content.delta", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + 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/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts new file mode 100644 index 00000000..675307b3 --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -0,0 +1,435 @@ +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 { AcpSessionRuntime, 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"); +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 }, + }, + }); + }).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; + const started = yield* runtime.start(); + + expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); + expect(started.sessionId).toBe("mock-session-1"); + + 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.getEvents(), 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); + } + }).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.getEvents(), 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); + } + }).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.getEvents(), 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"); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: bunExe, + args: [mockAgentPath], + env: { + T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1", + }, + }, + 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", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + 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); + }).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("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("uses the protocol-native session/set_mode RPC for mode changes", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.setMode("code"); + + expect( + requestEvents.some( + (event) => event.method === "session/set_mode" && event.status === "started", + ), + ).toBe(true); + expect( + requestEvents.some( + (event) => event.method === "session/set_config_option" && event.status === "started", + ), + ).toBe(false); + expect((yield* runtime.getModeState)?.currentModeId).toBe("code"); + }).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* () { + const runtime = yield* AcpSessionRuntime; + yield* runtime.start(); + + 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); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + 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); + }), + }, + }), + ), + 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"); + 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]"); + } + + 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); + }).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), + Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts new file mode 100644 index 00000000..2fb3f4e8 --- /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.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts new file mode 100644 index 00000000..ae12d311 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -0,0 +1,286 @@ +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", + kind: "execute", + title: "Ran command", + 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", + title: "Ran command", + detail: "bun run typecheck", + command: "bun run typecheck", + }); + } + }); + + 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", + 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({ + kind: "execute", + detail: "cat package.json", + toolCall: { + toolCallId: "tool-1", + 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 new file mode 100644 index 00000000..ffd214a5 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -0,0 +1,482 @@ +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); +} + +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 kind?: string; + 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 kind: string | "unknown"; + readonly detail?: string; + readonly toolCall?: AcpToolCallState; +} + +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; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ToolCallUpdated"; + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ContentDelta"; + readonly itemId?: string; + 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 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 { + 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 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; + 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 data: Record = { toolCallId }; + const kind = normalizeToolKind(input.kind); + if (kind) { + data.kind = 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 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 } : {}), + ...(presentation?.summary ? { title: presentation.summary } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(presentation?.detail ? { detail: presentation.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 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, + ...(kind ? { kind } : {}), + ...(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 kind = normalizeToolKind(params.toolCall.kind) ?? "unknown"; + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + kind, + ...(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.trim(); + if (modeId) { + 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 00000000..1181def2 --- /dev/null +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -0,0 +1,731 @@ +import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Context, 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, + 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 clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; + readonly clientInfo: { + readonly name: string; + readonly version: string; + }; + readonly authMethodId: string; + 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 AcpSessionRuntimeStartResult { + readonly sessionId: string; + readonly initializeResult: EffectAcpSchema.InitializeResponse; + readonly sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly modelConfigId: string | undefined; +} + +export interface AcpSessionRuntimeShape { + 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 getEvents: () => Stream.Stream; + readonly getModeState: Effect.Effect; + readonly getConfigOptions: 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; +} + +interface AcpStartedState extends AcpSessionRuntimeStartResult {} + +type AcpStartState = + | { readonly _tag: "NotStarted" } + | { + readonly _tag: "Starting"; + readonly deferred: Deferred.Deferred; + } + | { 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 Context.Service()( + "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< + AcpSessionRuntimeShape, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + 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" }); + + 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( + 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 acpContext = yield* Layer.build( + EffectAcpClient.layerChildProcess(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 acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + + yield* acp.handleSessionUpdate((notification) => + handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + toolCallsRef, + assistantSegmentRef, + params: notification, + }), + ); + + 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") { + return state.result; + } + 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, + 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 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) => + 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* () { + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + + const initializeResult = yield* runLoggedRequest( + "initialize", + initializePayload, + acp.agent.initialize(initializePayload), + ); + + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.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.agent.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.agent.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.agent.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 { + 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, + getEvents: () => Stream.fromQueue(eventQueue), + getModeState: Ref.get(modeStateRef), + getConfigOptions: Ref.get(configOptionsRef), + prompt: (payload) => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + return closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }).pipe( + Effect.andThen( + runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }), + ), + cancel: getStartedState.pipe( + Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), + ), + setMode: (modeId) => + Ref.get(modeStateRef).pipe( + Effect.flatMap((modeState) => { + if (modeState?.currentModeId === modeId) { + return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); + } + return getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + modeId, + } satisfies EffectAcpSchema.SetSessionModeRequest; + return runLoggedRequest( + "session/set_mode", + requestPayload, + acp.agent.setSessionMode(requestPayload), + ).pipe(Effect.tap(() => updateCurrentModeId(modeId))); + }), + ); + }), + ), + setConfigOption, + setModel: (model) => + getStartedState.pipe( + Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.asVoid, + ), + request: (method, payload) => + runLoggedRequest(method, payload, acp.raw.request(method, payload)), + notify: acp.raw.notify, + } satisfies AcpSessionRuntimeShape; + }); + +function sessionConfigOptionsFromSetup( + response: + | { + readonly configOptions?: ReadonlyArray | null; + } + | undefined, +): ReadonlyArray { + 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, + 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* () { + 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") { + 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); + if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { + next.delete(nextToolCall.toolCallId); + } else { + next.set(nextToolCall.toolCallId, nextToolCall); + } + return [{ previous, merged: nextToolCall }, next] as const; + }); + if (!shouldEmitToolCallUpdate(previous, merged)) { + continue; + } + yield* Queue.offer(queue, { + _tag: "ToolCallUpdated", + toolCall: merged, + rawPayload: event.rawPayload, + }); + 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); + } + }); + +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; +} + +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/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts new file mode 100644 index 00000000..7744e24a --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -0,0 +1,148 @@ +/** + * 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 type * as EffectAcpSchema from "effect-acp/schema"; + +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* AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + 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* AcpSessionRuntime; + const started = yield* runtime.start(); + const result = started.sessionSetupResult; + console.log("session/new result:", JSON.stringify(result, null, 2)); + + expect(typeof started.sessionId).toBe("string"); + + const configOptions = result.configOptions; + console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); + + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find((opt) => opt.category === "model"); + const parameterizedOptions = configOptions.filter( + (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( + "Parameterized model config options:", + JSON.stringify(parameterizedOptions, null, 2), + ); + expect(modelConfig).toBeDefined(); + expect(typeof modelConfig?.id).toBe("string"); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + 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* AcpSessionRuntime; + const started = yield* runtime.start(); + const newResult = started.sessionSetupResult; + + const configOptions = newResult.configOptions; + let modelConfigId = "model"; + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find((opt) => opt.category === "model"); + if (typeof modelConfig?.id === "string") { + modelConfigId = modelConfig.id; + } + } + + const setResult: EffectAcpSchema.SetSessionConfigOptionResponse = + 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_option" || + opt.category === "model_config", + ); + if (modelConfig?.type === "select") { + expect(modelConfig.currentValue).toBe("gpt-5.4"); + } + expect(parameterizedOptions.length).toBeGreaterThan(0); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "cursor_login", + spawn: { + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }, + cwd: process.cwd(), + clientCapabilities: { + _meta: { + parameterizedModelPicker: true, + }, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }), + ), + 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 00000000..91d50c4a --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -0,0 +1,108 @@ +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?", + multiSelect: false, + options: [ + { label: "TypeScript", description: "TypeScript" }, + { label: "Rust", description: "Rust" }, + ], + }, + ]); + }); + + 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", + 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 and drops invalid entries", () => { + expect( + extractTodosAsPlan({ + toolCallId: "todos-1", + todos: [ + { 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: "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 new file mode 100644 index 00000000..dff65535 --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -0,0 +1,99 @@ +/** + * 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"; + +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.optional(Schema.Boolean), +}); + +export const CursorAskQuestionRequest = Schema.Struct({ + toolCallId: Schema.String, + title: Schema.optional(Schema.String), + questions: Schema.Array(CursorAskQuestion), +}); + +const CursorTodoStatus = Schema.String; + +const CursorTodo = Schema.Struct({ + id: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + status: Schema.optional(CursorTodoStatus), +}); + +const CursorPlanPhase = Schema.Struct({ + name: Schema.String, + todos: Schema.Array(CursorTodo), +}); + +export const CursorCreatePlanRequest = Schema.Struct({ + toolCallId: 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.Struct({ + toolCallId: Schema.String, + todos: Schema.Array(CursorTodo), + merge: Schema.Boolean, +}); + +export function extractAskQuestions( + params: typeof CursorAskQuestionRequest.Type, +): ReadonlyArray { + return params.questions.map((question) => ({ + id: question.id, + header: "Question", + question: question.prompt, + multiSelect: question.allowMultiple === true, + 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"; + }>; +} { + const plan = params.todos.flatMap((todo) => { + const step = todo.content?.trim() ?? todo.title?.trim() ?? ""; + if (step === "") { + return []; + } + const status: "pending" | "inProgress" | "completed" = + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" || todo.status === "inProgress" + ? "inProgress" + : "pending"; + return [{ step, status }]; + }); + return { plan }; +} 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 00000000..94de569b --- /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-medium-fast", + options: [{ value: "gpt-5.4-medium-fast", 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-medium-fast[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-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/acp/CursorAcpSupport.ts b/apps/server/src/provider/acp/CursorAcpSupport.ts new file mode 100644 index 00000000..72b9af39 --- /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, Scope } 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)); + }); + +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, + }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts new file mode 100644 index 00000000..31fe73a4 --- /dev/null +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -0,0 +1,263 @@ +import { describe, it, assert } from "@effect/vitest"; +import type { ServerProvider } from "@t3tools/contracts"; +import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect"; + +import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; + +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: [], + }, + }, + ], +}; + +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( + "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; + assert.deepStrictEqual(initial, initialSnapshot); + + const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Deferred.succeed(releaseCheck, 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); + }), + ), + ); + + it.effect("reruns the provider check when streamed settings change", () => + Effect.scoped( + Effect.gen(function* () { + 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.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, 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, refreshedSnapshotSecond]); + assert.deepStrictEqual(latest, refreshedSnapshotSecond); + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ), + ); + + it.effect("streams supplemental snapshot updates after the base provider check completes", () => + 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: 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, + ); + yield* Effect.yieldNow; + + 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); + }), + ), + ); + + 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 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.flatMap((count) => + count === 1 + ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) + : Effect.succeed(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* Deferred.succeed(allowFirstRefresh, undefined); + 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 1d3bf52f..4787a9f9 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,10 +1,15 @@ 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.ts"; import { ServerSettingsError } from "@t3tools/contracts"; +interface ProviderSnapshotState { + readonly snapshot: ServerProvider; + readonly enrichmentGeneration: number; +} + export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < Settings, >(input: { @@ -13,6 +18,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); @@ -22,8 +33,61 @@ 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 scope = yield* Effect.scope; + + const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* ( + generation: number, + nextSnapshot: ServerProvider, + ) { + 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* 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) { + yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); + } + + if (!input.enrichSnapshot) { + return; + } + + const fiber = yield* input + .enrichSnapshot({ + settings, + snapshot, + getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), + 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, @@ -33,13 +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, nextGeneration); return nextSnapshot; }); const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => @@ -61,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/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000..0ea63f8d --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; + +import { describe, it, vi } from "vitest"; + +const childProcessMock = vi.hoisted(() => ({ + execFileSync: vi.fn((command: string, args: ReadonlyArray) => { + if (command === "which" && args[0] === "opencode") { + return "/opt/homebrew/bin/opencode\n"; + } + return ""; + }), + spawn: vi.fn(), +})); + +vi.mock("node:child_process", () => childProcessMock); + +describe("resolveOpenCodeBinaryPath", () => { + it("returns absolute binary paths without PATH lookup", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); + assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); + }); + + it("resolves command names through PATH", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); + assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ + "which", + ["opencode"], + { + encoding: "utf8", + timeout: 3_000, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts new file mode 100644 index 00000000..4778f6ea --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -0,0 +1,573 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import * as FS from "node:fs"; +import { createServer, type AddressInfo } from "node:net"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { + ChatAttachment, + ModelCapabilities, + ProviderApprovalDecision, + RuntimeMode, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type FilePartInput, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; + +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; + +const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const ANTHROPIC_VARIANTS = ["high", "max"]; +const GOOGLE_VARIANTS = ["low", "high"]; + +export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +export interface OpenCodeServerProcess { + readonly url: string; + readonly process: ChildProcess; + close(): void; +} + +export interface OpenCodeServerConnection { + readonly url: string; + readonly process: ChildProcess | null; + readonly external: boolean; + close(): void; +} + +function buildOpenCodeBasicAuthorizationHeader(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; +} + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + continue; + } + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + return match?.[1] ?? null; + } + return null; +} + +function isPrimaryAgent(agent: Agent): boolean { + return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); +} + +function inferVariantValues(providerID: string): ReadonlyArray { + if (providerID === "anthropic") { + return ANTHROPIC_VARIANTS; + } + if (providerID === "openai" || providerID === "opencode") { + return OPENAI_VARIANTS; + } + if (providerID.startsWith("google")) { + return GOOGLE_VARIANTS; + } + return []; +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function buildVariantOptions( + providerID: string, + model: ProviderListResponse["all"][number]["models"][string], +) { + const variantValues = Object.keys(model.variants ?? {}); + const resolvedValues = + variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; + const defaultVariant = inferDefaultVariant(providerID, resolvedValues); + + return resolvedValues.map((value) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value, + label: titleCaseSlug(value), + }; + if (defaultVariant === value) { + option.isDefault = true; + } + return option; + }); +} + +function buildAgentOptions(agents: ReadonlyArray) { + const primaryAgents = agents.filter(isPrimaryAgent); + const defaultAgent = + primaryAgents.find((agent) => agent.name === "build")?.name ?? + primaryAgents[0]?.name ?? + undefined; + return primaryAgents.map((agent) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value: agent.name, + label: titleCaseSlug(agent.name), + }; + if (defaultAgent === agent.name) { + option.isDefault = true; + } + return option; + }); +} + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantOptions = buildVariantOptions(input.providerID, input.model); + const agentOptions = buildAgentOptions(input.agents); + return { + ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ...(variantOptions.length > 0 ? { variantOptions } : {}), + ...(agentOptions.length > 0 ? { agentOptions } : {}), + }; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function toOpenCodeModelSlug(providerID: string, modelID: string): string { + return `${providerID}/${modelID}`; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + return [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +export async function findAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, DEFAULT_HOSTNAME, () => resolve()); + }); + const address = server.address() as AddressInfo; + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +export function resolveOpenCodeBinaryPath(binaryPath: string): string { + if (Path.isAbsolute(binaryPath)) { + return binaryPath; + } + return execFileSync("which", [binaryPath], { + encoding: "utf8", + timeout: 3_000, + }).trim(); +} + +export function detectMacosSigkillHint(binaryPath: string): string | null { + try { + // Check for quarantine xattr first. + const resolvedPath = resolveOpenCodeBinaryPath(binaryPath); + const xattr = execFileSync("xattr", ["-l", resolvedPath], { + encoding: "utf8", + timeout: 3_000, + }); + if (xattr.includes("com.apple.quarantine")) { + return ( + `macOS quarantine is blocking the OpenCode binary. ` + + `Run: xattr -d com.apple.quarantine ${resolvedPath}` + ); + } + + // Look for a recent crash report with the termination reason. + const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports"); + const binaryName = Path.basename(resolvedPath); + const recentReports = FS.readdirSync(crashDir) + .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips")) + .toSorted() + .toReversed() + .slice(0, 1); + + for (const report of recentReports) { + const content = FS.readFileSync(Path.join(crashDir, report), "utf8"); + if (content.includes('"namespace":"CODESIGNING"')) { + return ( + "macOS killed the process due to an invalid code signature. " + + "The binary may be corrupted — try reinstalling OpenCode." + ); + } + } + } catch { + // Best-effort detection — don't fail the original error path. + } + return null; +} + +export async function startOpenCodeServerProcess(input: { + readonly binaryPath: string; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = input.port ?? (await findAvailablePort()); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const child = spawn(input.binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + let stdout = ""; + let stderr = ""; + let closed = false; + const close = () => { + if (closed) { + return; + } + closed = true; + child.kill(); + }; + + const url = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + child.off("error", onError); + child.off("close", onClose); + }; + + const onStdout = (chunk: string) => { + stdout += chunk; + const parsed = parseServerUrlFromOutput(stdout); + if (!parsed) { + return; + } + cleanup(); + resolve(parsed); + }; + + const onStderr = (chunk: string) => { + stderr += chunk; + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`; + const hint = + signal === "SIGKILL" && process.platform === "darwin" + ? detectMacosSigkillHint(input.binaryPath) + : null; + reject( + new Error( + [ + `OpenCode server exited before startup completed (${exitReason}).`, + hint, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + ), + ); + }; + + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + child.once("error", onError); + child.once("close", onClose); + }); + + return { + url, + process: child, + close, + }; +} + +export async function connectToOpenCodeServer(input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + return { + url: serverUrl, + process: null, + external: true, + close() {}, + }; + } + + const server = await startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + + return { + url: server.url, + process: server.process, + external: false, + close: () => server.close(), + }; +} + +export async function runOpenCodeCommand(input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; +}): Promise { + const child = spawn(input.binaryPath, [...input.args], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + env: process.env, + }); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + + child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); + + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode) => resolve(exitCode ?? 0)); + }); + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + code, + }; +} + +export function createOpenCodeSdkClient(input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; +}): OpencodeClient { + return createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword), + }, + } + : {}), + throwOnError: true, + }); +} + +export async function loadOpenCodeInventory(client: OpencodeClient): Promise { + const [providerListResult, agentsResult] = await Promise.all([ + client.provider.list(), + client.app.agents(), + ]); + if (!providerListResult.data) { + throw new Error("OpenCode provider inventory was empty."); + } + return { + providerList: providerListResult.data, + agents: agentsResult.data ?? [], + }; +} + +export function flattenOpenCodeModels( + input: OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + models.push({ + slug: toOpenCodeModelSlug(provider.id, model.id), + name: `${provider.name} · ${model.name}`, + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 00000000..0a0d31cc --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], + agentOptions: [{ value: "build", label: "Build", isDefault: true }], +}; + +describe("providerModelsFromSettings", () => { + it("applies the provided capabilities to custom models", () => { + const models = providerModelsFromSettings( + [], + "opencode", + ["openai/gpt-5"], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "openai/gpt-5", + name: "openai/gpt-5", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2b0fc9dc..1ee49083 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -148,9 +148,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, - ...(input.probe.quotaSnapshots !== undefined - ? { quotaSnapshots: [...input.probe.quotaSnapshots] } - : {}), + ...(input.probe.quotaSnapshots ? { quotaSnapshots: [...input.probe.quotaSnapshots] } : {}), slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], }; diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index c5efb881..51576898 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { status: "warning", auth: { status: "unknown" }, }); + const openCodeProvider = makeProvider("opencode", { + status: "warning", + auth: { status: "unknown", type: "opencode" }, + }); const codexPath = resolveProviderStatusCachePath({ cacheDir: tempDir, provider: "codex", @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { cacheDir: tempDir, provider: "claudeAgent", }); + const openCodePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "opencode", + }); yield* writeProviderStatusCache({ filePath: codexPath, @@ -54,16 +62,72 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { filePath: claudePath, provider: claudeProvider, }); + yield* writeProviderStatusCache({ + filePath: openCodePath, + provider: openCodeProvider, + }); assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); }), ); - it("hydrates cached provider status onto current settings-derived models", () => { + it.effect("ignores stale writes when a newer provider snapshot is already cached", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-stale-" }); + const filePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "codex", + }); + const newerProvider = makeProvider("codex", { + checkedAt: "2026-04-11T01:00:00.000Z", + version: "2.0.0", + }); + const olderProvider = makeProvider("codex", { + checkedAt: "2026-04-11T00:00:00.000Z", + version: "1.0.0", + }); + + yield* writeProviderStatusCache({ filePath, provider: newerProvider }); + yield* writeProviderStatusCache({ filePath, provider: olderProvider }); + + assert.deepStrictEqual(yield* readProviderStatusCache(filePath), newerProvider); + }), + ); + + it("hydrates cached provider status while keeping settings-derived model membership authoritative", () => { const cachedCodex = makeProvider("codex", { checkedAt: "2026-04-10T12:00:00.000Z", - models: [], + models: [ + { + slug: "gpt-5-mini", + name: "GPT-5 Mini", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + billingMultiplier: 2, + maxContextWindowTokens: 128_000, + }, + ], message: "Cached message", skills: [ { @@ -99,6 +163,20 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { }), { ...fallbackCodex, + models: [ + { + ...fallbackCodex.models[0]!, + billingMultiplier: 2, + maxContextWindowTokens: 128_000, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], installed: cachedCodex.installed, version: cachedCodex.version, status: cachedCodex.status, @@ -111,148 +189,34 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); - it("preserves cached runtime-discovered models during cache hydration", () => { - const cachedCopilot = makeProvider("copilot", { - models: [ - { - slug: "gpt-5", - name: "GPT-5", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4.7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - const fallbackCopilot = makeProvider("copilot", { - models: [ - { - slug: "gpt-5", - name: "GPT-5 fallback", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - assert.deepStrictEqual( - hydrateCachedProvider({ - cachedProvider: cachedCopilot, - fallbackProvider: fallbackCopilot, - }).models, - cachedCopilot.models, - ); - }); - - it("does not resurrect removed cached custom models during cache hydration", () => { - const cachedClaude = makeProvider("claudeAgent", { + it("does not resurrect cached-only models after they are removed from current settings", () => { + const cachedCodex = makeProvider("codex", { models: [ { - slug: "claude-custom-removed", - name: "Claude Custom Removed", + slug: "gpt-legacy-custom", + name: "GPT Legacy Custom", isCustom: true, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-runtime-discovered", - name: "Claude Runtime Discovered", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: null, }, ], }); - const fallbackClaude = makeProvider("claudeAgent", { + const fallbackCodex = makeProvider("codex", { models: [ { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", + slug: "gpt-5.4", + name: "GPT-5.4", isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, + capabilities: null, }, ], }); assert.deepStrictEqual( hydrateCachedProvider({ - cachedProvider: cachedClaude, - fallbackProvider: fallbackClaude, + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, }).models, - [fallbackClaude.models[0]!, cachedClaude.models[1]!], - ); - }); - - it("preserves missing quota snapshots during cache hydration", () => { - const cachedCopilot = makeProvider("copilot", { - quotaSnapshots: undefined, - }); - const fallbackCopilot = makeProvider("copilot", { - quotaSnapshots: [ - { - key: "premium_interactions", - entitlementRequests: 100, - usedRequests: 25, - remainingPercentage: 75, - overage: 0, - overageAllowedWithExhaustedQuota: false, - }, - ], - }); - - assert.deepStrictEqual( - hydrateCachedProvider({ - cachedProvider: cachedCopilot, - fallbackProvider: fallbackCopilot, - }), - { - ...fallbackCopilot, - installed: cachedCopilot.installed, - version: cachedCopilot.version, - status: cachedCopilot.status, - auth: cachedCopilot.auth, - checkedAt: cachedCopilot.checkedAt, - slashCommands: cachedCopilot.slashCommands, - skills: cachedCopilot.skills, - }, + fallbackCodex.models, ); }); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index fece8d68..f0674ac5 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,22 +1,49 @@ import * as nodePath from "node:path"; import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; -import { Cause, Effect, FileSystem, Path, Schema } from "effect"; +import { Cause, Effect, FileSystem, Path, Schema, Semaphore } from "effect"; export const PROVIDER_CACHE_IDS = [ "codex", "copilot", "claudeAgent", + "opencode", + "cursor", ] as const satisfies ReadonlyArray; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); +const cacheWriteSemaphoreByPath = new Map(); + const providerOrderRank = (provider: ServerProvider["provider"]): number => { const rank = PROVIDER_CACHE_IDS.indexOf(provider); return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; }; +const mergeProviderModels = ( + fallbackModels: ReadonlyArray, + cachedModels: ReadonlyArray, +): ReadonlyArray => { + if (cachedModels.length === 0) { + return fallbackModels; + } + const cachedBySlug = new Map(cachedModels.map((model) => [model.slug, model] as const)); + return fallbackModels.map((fallbackModel) => { + const cachedModel = cachedBySlug.get(fallbackModel.slug); + if (!cachedModel) { + return fallbackModel; + } + return { + ...fallbackModel, + billingMultiplier: fallbackModel.billingMultiplier ?? cachedModel.billingMultiplier, + maxContextWindowTokens: + fallbackModel.maxContextWindowTokens ?? cachedModel.maxContextWindowTokens, + capabilities: fallbackModel.capabilities ?? cachedModel.capabilities, + }; + }); +}; + export const orderProviderSnapshots = ( providers: ReadonlyArray, ): ReadonlyArray => @@ -35,34 +62,20 @@ export const hydrateCachedProvider = (input: { return input.fallbackProvider; } - const mergedModels = (() => { - const modelsBySlug = new Map(); - for (const model of input.fallbackProvider.models) { - modelsBySlug.set(model.slug, model); - } - for (const model of input.cachedProvider.models) { - if (model.isCustom && !modelsBySlug.has(model.slug)) { - continue; - } - modelsBySlug.set(model.slug, model); - } - return [...modelsBySlug.values()]; - })(); - const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, + models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), installed: input.cachedProvider.installed, version: input.cachedProvider.version, status: input.cachedProvider.status, auth: input.cachedProvider.auth, checkedAt: input.cachedProvider.checkedAt, - ...(input.cachedProvider.quotaSnapshots !== undefined - ? { quotaSnapshots: input.cachedProvider.quotaSnapshots } - : {}), - models: mergedModels, slashCommands: input.cachedProvider.slashCommands, skills: input.cachedProvider.skills, + ...(input.cachedProvider.quotaSnapshots + ? { quotaSnapshots: input.cachedProvider.quotaSnapshots } + : {}), }; return input.cachedProvider.message @@ -101,25 +114,60 @@ export const readProviderStatusCache = (filePath: string) => ); }); +const parseCheckedAt = (value: string): number => { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : Number.NaN; +}; + +const isStaleCacheWrite = (input: { + readonly current: ServerProvider | undefined; + readonly next: ServerProvider; +}): boolean => { + if (!input.current || input.current.provider !== input.next.provider) { + return false; + } + const currentCheckedAt = parseCheckedAt(input.current.checkedAt); + const nextCheckedAt = parseCheckedAt(input.next.checkedAt); + if (!Number.isFinite(currentCheckedAt) || !Number.isFinite(nextCheckedAt)) { + return false; + } + return currentCheckedAt > nextCheckedAt; +}; + +const getCacheWriteSemaphore = (filePath: string): Semaphore.Semaphore => { + const existing = cacheWriteSemaphoreByPath.get(filePath); + if (existing) { + return existing; + } + const semaphore = Effect.runSync(Semaphore.make(1)); + cacheWriteSemaphoreByPath.set(filePath, semaphore); + return semaphore; +}; + export const writeProviderStatusCache = (input: { readonly filePath: string; readonly provider: ServerProvider; -}) => { - const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; - - yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); - yield* fs.writeFileString(tempPath, encoded); - yield* fs.rename(tempPath, input.filePath); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); - }), - ), +}) => + getCacheWriteSemaphore(input.filePath).withPermits(1)( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const currentProvider = yield* readProviderStatusCache(input.filePath).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + if (isStaleCacheWrite({ current: currentProvider, next: input.provider })) { + return; + } + + const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; + const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; + + yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fs.writeFileString(tempPath, encoded); + yield* fs + .rename(tempPath, input.filePath) + .pipe( + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + ); + }), ); -}; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5e980ecf..1827fc40 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -75,7 +75,6 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; -import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -88,6 +87,7 @@ import { BrowserTraceCollector, type BrowserTraceCollectorShape, } from "./observability/Services/BrowserTraceCollector.ts"; +import { PersistenceSqlError } from "./persistence/Errors.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { ProjectSetupScriptRunner, @@ -194,7 +194,7 @@ const makeDefaultOrchestrationThreadShell = ( }; }; -const _workspaceAndProjectServicesLayer = Layer.mergeAll( +const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), WorkspaceFileSystemLive.pipe( @@ -1837,6 +1837,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const refreshCalls: string[] = []; const providers = [ { provider: "codex" as const, @@ -1871,6 +1872,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, providerRegistry: { getProviders: Effect.succeed(providers), + refresh: (provider) => + Effect.sync(() => { + if (provider) { + refreshCalls.push(provider); + } + return providers; + }), }, }, }); @@ -1902,6 +1910,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { type: "keybindingsUpdated", payload: { issues: [] }, }); + assert.deepEqual(refreshCalls.toSorted(), [ + "claudeAgent", + "codex", + "copilot", + "cursor", + "opencode", + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3193,6 +3208,64 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("archives and still stops the provider session when the precheck lookup fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-precheck-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: (_threadId) => + Effect.fail( + new PersistenceSqlError({ + operation: "getThreadShellById", + detail: "simulated projection precheck failure", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-precheck-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("archives without dispatching session stop when the thread has no session", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive-no-session"); @@ -3461,64 +3534,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("archives and stops the session defensively when snapshot lookup fails", () => - Effect.gen(function* () { - const threadId = ThreadId.make("thread-archive-lookup-failure"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - - yield* buildAppUnderTest({ - layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, - orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.fail( - new PersistenceSqlError({ - operation: "getThreadShellById", - detail: "simulated thread lookup failure", - }), - ), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ - type: "thread.archive", - commandId: CommandId.make("cmd-thread-archive-lookup-failure"), - threadId, - }), - ), - ); - - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, [ - "dispatch:thread.archive", - "dispatch:thread.session.stop", - `terminal.close:${threadId}`, - ]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.archive", "thread.session.stop"], - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect( "bootstraps first-send worktree turns on the server before dispatching turn start", () => diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1a93b49d..aa802880 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,8 +20,10 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; -import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter.ts"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { makeCopilotAdapterLive } from "./provider/Layers/CopilotAdapter.ts"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts"; +import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -40,6 +42,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus. import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -128,6 +131,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), + Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -152,16 +156,24 @@ const ProviderLayerLive = Layer.unwrap( const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const claudeAdapterLayer = makeClaudeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const copilotAdapterLayer = makeCopilotAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); - const claudeAdapterLayer = makeClaudeAdapterLive( + const openCodeAdapterLayer = makeOpenCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + const cursorAdapterLayer = makeCursorAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(copilotAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(openCodeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 30b086ae..655ede94 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -41,23 +41,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, ); - - assert.deepEqual( - decodePatch({ - providers: { - claudeAgent: { - launchArgs: "--verbose --dangerously-skip-permissions", - }, - }, - }), - { - providers: { - claudeAgent: { - launchArgs: "--verbose --dangerously-skip-permissions", - }, - }, - }, - ); }), ); @@ -188,61 +171,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("falls back from unsupported copilot git text generation selections", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - provider: "copilot", - model: "gpt-5-mini", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - provider: "codex", - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - - it.effect("persists a disabled selected provider while read-time access still falls back", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - - const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - enabled: false, - }, - }, - textGenerationModelSelection: { - provider: "codex", - model: "gpt-5.4", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - provider: "claudeAgent", - model: "claude-haiku-4-5", - }); - - const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); - assert.deepEqual(persisted.textGenerationModelSelection, { - provider: "codex", - model: "gpt-5.4", - }); - - const readBack = yield* serverSettings.getSettings; - assert.deepEqual(readBack.textGenerationModelSelection, { - provider: "claudeAgent", - model: "claude-haiku-4-5", - }); - }).pipe(Effect.provide(makeServerSettingsLayer())), - ); - it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; @@ -256,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + opencode: { + binaryPath: " /opt/homebrew/bin/opencode ", + serverUrl: " http://127.0.0.1:4096 ", + serverPassword: " secret-password ", + }, }, }); @@ -271,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], launchArgs: "", }); + assert.deepEqual(next.providers.opencode, { + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + customModels: [], + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -329,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); @@ -345,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); }).pipe(Effect.provide(makeServerSettingsLayer())), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a50aaeba..614dd5e6 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -79,7 +79,7 @@ export class ServerSettingsService extends Context.Service< return { start: Effect.void, ready: Effect.void, - getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), + getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( Effect.flatMap((currentSettings) => @@ -97,7 +97,6 @@ export class ServerSettingsService extends Context.Service< ), ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), - Effect.map(resolveTextGenerationProvider), ), streamChanges: Stream.empty, } satisfies ServerSettingsShape; @@ -107,7 +106,13 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "copilot", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = [ + "codex", + "copilot", + "claudeAgent", + "opencode", + "cursor", +]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. @@ -117,18 +122,18 @@ const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "copilot", "claudeAgen */ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; - if (selection.provider === "codex" || selection.provider === "claudeAgent") { - if (settings.providers[selection.provider].enabled) { - return settings; - } + if (settings.providers[selection.provider].enabled) { + return settings; } const fallback = PROVIDER_ORDER.find( (provider): provider is (typeof GIT_TEXT_GENERATION_PROVIDERS)[number] => - (provider === "codex" || provider === "claudeAgent") && settings.providers[provider].enabled, + GIT_TEXT_GENERATION_PROVIDERS.includes( + provider as (typeof GIT_TEXT_GENERATION_PROVIDERS)[number], + ) && settings.providers[provider].enabled, ); if (!fallback) { - // No supported providers enabled — return as-is; callers will report the error. + // No providers enabled — return as-is; callers will report the error. return settings; } @@ -347,11 +352,10 @@ const makeServerSettings = Effect.gen(function* () { }), ), ); - const resolvedNext = resolveTextGenerationProvider(next); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(resolvedNext); - return resolvedNext; + yield* emitChange(next); + return resolveTextGenerationProvider(next); }), ), get streamChanges() { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 48f6b615..1fa48aed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -562,15 +562,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => thread.session !== null && thread.session.status !== "stopped", }), ), - Effect.catchCause((cause) => - Effect.logWarning( - "failed to inspect thread session before archive; stopping session defensively", - { - threadId: normalizedCommand.threadId, - cause, - }, - ).pipe(Effect.as(true)), - ), + Effect.catch(() => Effect.succeed(true)), ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); @@ -992,7 +984,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ); yield* Effect.all( - [providerRegistry.refresh("codex"), providerRegistry.refresh("claudeAgent")], + [ + providerRegistry.refresh("codex"), + providerRegistry.refresh("copilot"), + providerRegistry.refresh("claudeAgent"), + providerRegistry.refresh("opencode"), + providerRegistry.refresh("cursor"), + ], { concurrency: "unbounded", discard: true, diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f11dd378..184909f0 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", diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index c08c4c1e..7da396d8 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; const testEnvironmentId = EnvironmentId.make("environment-1"); @@ -37,21 +41,15 @@ function getTestWindow(): Window & typeof globalThis { const testWindow = { localStorage, } as Window & typeof globalThis; - Object.defineProperty(globalThis, "window", { - configurable: true, - value: testWindow, - }); - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: localStorage, - }); + vi.stubGlobal("window", testWindow); + vi.stubGlobal("localStorage", localStorage); return testWindow; } afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); - Reflect.deleteProperty(globalThis, "window"); - Reflect.deleteProperty(globalThis, "localStorage"); }); describe("clientPersistenceStorage", () => { @@ -84,20 +82,17 @@ describe("clientPersistenceStorage", () => { }); }); - it("migrates legacy browser client settings during hydration", async () => { + it("migrates legacy browser client settings into the current storage key", async () => { const testWindow = getTestWindow(); testWindow.localStorage.setItem( "t3code:app-settings:v1", JSON.stringify({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, + confirmThreadArchive: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { - "/repo": "separate", + "env:/workspace/project-a": "separate", }, sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }), ); @@ -106,29 +101,25 @@ describe("clientPersistenceStorage", () => { await import("./clientPersistenceStorage"); expect(readBrowserClientSettings()).toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, + ...DEFAULT_CLIENT_SETTINGS, + confirmThreadArchive: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { - "/repo": "separate", + "env:/workspace/project-a": "separate", }, sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }); + expect(testWindow.localStorage.getItem("t3code:app-settings:v1")).toBeNull(); expect(JSON.parse(testWindow.localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY)!)).toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, + ...DEFAULT_CLIENT_SETTINGS, + confirmThreadArchive: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { - "/repo": "separate", + "env:/workspace/project-a": "separate", }, sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }); - expect(testWindow.localStorage.getItem("t3code:app-settings:v1")).toBeNull(); }); }); diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 42f0ab80..a397d52a 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -138,49 +138,4 @@ describe("ChatMarkdown", () => { await screen.unmount(); } }); - - it("renders bare file urls as clickable file links", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L8" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}#L8`); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:8`); - }); - } finally { - await screen.unmount(); - } - }); - - it("keeps trailing punctuation outside bare file URL editor targets", async () => { - const filePath = - "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; - const screen = await render( - , - ); - - try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L8" }); - await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}#L8`); - await expect.element(page.getByText(", then continue")).toBeInTheDocument(); - - await link.click(); - - await vi.waitFor(() => { - expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:8`); - }); - } finally { - await screen.unmount(); - } - }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d56551b6..ba1c944c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -252,25 +252,6 @@ interface MarkdownFileLinkProps { } const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; -const STANDALONE_FILE_URL_PATTERN = /\bfile:\/\/\/[^\s<>()]+/gi; -const STANDALONE_FILE_URL_TRAILING_PUNCTUATION_PATTERN = /[.,!?;:]+$/; - -function splitStandaloneFileUrlCandidate(value: string): { href: string; trailingText: string } { - if (value.length === 0) { - return { href: value, trailingText: "" }; - } - - const trailingPunctuationMatch = value.match(STANDALONE_FILE_URL_TRAILING_PUNCTUATION_PATTERN); - const trailingText = trailingPunctuationMatch?.[0] ?? ""; - if (!trailingText) { - return { href: value, trailingText: "" }; - } - - return { - href: value.slice(0, value.length - trailingText.length), - trailingText, - }; -} const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; @@ -346,131 +327,10 @@ function extractMarkdownLinkHrefs(text: string): string[] { return hrefs; } -function extractStandaloneFileUrlHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(STANDALONE_FILE_URL_PATTERN)) { - const href = match[0]?.trim(); - if (!href) continue; - const candidate = splitStandaloneFileUrlCandidate(href); - if (!candidate.href) continue; - hrefs.push(candidate.href); - } - return hrefs; -} - function normalizeMarkdownLinkHrefKey(href: string): string { return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); } -function remarkStandaloneFileUrls() { - return (tree: { - children?: Array<{ - type?: string; - value?: string; - children?: Array; - }>; - }) => { - const visitChildren = ( - parent: { - children?: Array<{ - type?: string; - value?: string; - children?: Array; - }>; - } | null, - ) => { - if (!parent?.children) { - return; - } - - for (let index = 0; index < parent.children.length; index += 1) { - const child = parent.children[index]; - if (!child || typeof child !== "object") { - continue; - } - - if (child.type === "text" && typeof child.value === "string") { - const matches = [...child.value.matchAll(STANDALONE_FILE_URL_PATTERN)]; - if (matches.length === 0) { - continue; - } - - const replacementNodes: Array> = []; - let lastIndex = 0; - for (const match of matches) { - const href = match[0]; - const matchIndex = match.index ?? -1; - if (!href || matchIndex < lastIndex) { - continue; - } - if (matchIndex > lastIndex) { - replacementNodes.push({ - type: "text", - value: child.value.slice(lastIndex, matchIndex), - }); - } - const candidate = splitStandaloneFileUrlCandidate(href); - if (!candidate.href) { - replacementNodes.push({ - type: "text", - value: href, - }); - lastIndex = matchIndex + href.length; - continue; - } - replacementNodes.push({ - type: "link", - url: candidate.href, - title: null, - children: [{ type: "text", value: candidate.href }], - }); - if (candidate.trailingText.length > 0) { - replacementNodes.push({ - type: "text", - value: candidate.trailingText, - }); - } - lastIndex = matchIndex + href.length; - } - - if (lastIndex < child.value.length) { - replacementNodes.push({ - type: "text", - value: child.value.slice(lastIndex), - }); - } - - parent.children.splice(index, 1, ...replacementNodes); - index += replacementNodes.length - 1; - continue; - } - - if ( - child.type === "link" || - child.type === "linkReference" || - child.type === "definition" || - child.type === "code" || - child.type === "inlineCode" - ) { - continue; - } - - visitChildren( - child as { - children?: Array<{ - type?: string; - value?: string; - children?: Array; - }>; - }, - ); - } - }; - - visitChildren(tree); - }; -} - const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, @@ -618,10 +478,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { string, NonNullable> >(); - for (const href of [ - ...extractMarkdownLinkHrefs(text), - ...extractStandaloneFileUrlHrefs(text), - ]) { + for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); @@ -642,10 +499,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { () => ({ a({ node: _node, href, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref - ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? - resolveMarkdownFileLinkMeta(normalizedHref, cwd)) - : null; + const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { return ; } @@ -697,7 +551,6 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { }), [ diffThemeName, - cwd, fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, @@ -708,7 +561,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 78b4acdd..47dad09e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10,6 +10,7 @@ import { type ProjectId, type ProviderApprovalDecision, type ServerProvider, + type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, @@ -25,7 +26,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -38,7 +39,6 @@ import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; -import { createModelSelection } from "../modelSelectionUtils"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -416,6 +416,7 @@ interface PersistentThreadTerminalDrawerProps { splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -428,6 +429,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel, newShortcutLabel, closeShortcutLabel, + keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); @@ -571,6 +573,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel={visible ? splitShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} + keybindings={keybindings} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} @@ -2287,8 +2290,8 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, terminalState.terminalOpen, @@ -2531,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = createModelSelection({ - provider: ctxSelectedProvider, - model: - ctxSelectedModel || + const threadCreateModelSelection = createModelSelection( + ctxSelectedProvider, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(ctxSelectedModelSelection.options - ? { options: ctxSelectedModelSelection.options } - : {}), - }); + ctxSelectedModelSelection.options, + ); // Auto-title from first message if (isFirstMessage && isServerThread) { @@ -3111,10 +3111,10 @@ export default function ChatView(props: ChatViewProps) { providerStatuses, model, ); - const nextModelSelection: ModelSelection = createModelSelection({ + const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, - }); + }; setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, @@ -3444,6 +3444,7 @@ export default function ChatView(props: ChatViewProps) { splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} /> ))} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 866db58f..b289d13e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -151,7 +151,7 @@ export function buildThreadActionItems { await input.runThread(thread); }, }; + return Object.assign( + item, + leadingContent ? { titleLeadingContent: leadingContent } : null, + trailingContent ? { titleTrailingContent: trailingContent } : null, + ); }); } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 8e2ec039..5ef52c6a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -173,9 +173,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); + const restoreSelectedFileScrollRef = useRef(false); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); - const restoreSelectedFileScrollRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadRef = useParams({ diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index a1299c3e..9a7ac5fb 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { export const OpenCodeIcon: Icon = (props) => ( - - + + + + diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 3e0e31aa..f6b150b0 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -80,7 +80,6 @@ function createBaseServerConfig(): ServerConfig { auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], - quotaSnapshots: [], slashCommands: [], skills: [], }, @@ -101,6 +100,14 @@ function createBaseServerConfig(): ServerConfig { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, copilot: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, + cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, + opencode: { + enabled: true, + binaryPath: "", + serverUrl: "", + serverPassword: "", + customModels: [], + }, }, }, }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ad6dd3a3..4d9664bd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -951,13 +951,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); - const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); - const getDraftThreadByProjectRef = useComposerDraftStore( - (state) => state.getDraftThreadByProjectRef, - ); - const clearProjectDraftThreadId = useComposerDraftStore( - (state) => state.clearProjectDraftThreadId, - ); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId; }>({ @@ -1283,6 +1276,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [projectGroupingSettings.sidebarProjectGroupingOverrides], ); + const removeProject = useCallback( + async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}): Promise => { + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + const draftStore = useComposerDraftStore.getState(); + const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); + if (projectDraftThread) { + draftStore.clearDraftThread(projectDraftThread.draftId); + } + draftStore.clearProjectDraftThreadId(memberProjectRef); + + const projectApi = readEnvironmentApi(member.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: member.id, + ...(options.force === true ? { force: true } : {}), + }); + }, + [], + ); + const handleRemoveProject = useCallback( async (member: SidebarProjectGroupMember) => { const api = readLocalApi(); @@ -1290,11 +1308,74 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { - toastManager.add({ + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; + if (memberThreadCount > 0) { + const warningToastId = toastManager.add({ type: "warning", title: "Project is not empty", description: "Delete all threads in this project before removing it.", + data: { + actionLayout: "stacked-end", + actionVariant: "destructive", + }, + actionProps: { + children: "Delete anyway", + onClick: () => { + void (async () => { + toastManager.close(warningToastId); + await new Promise((resolve) => { + window.setTimeout(resolve, 180); + }); + + const latestProjectThreads = selectSidebarThreadsForProjectRefs( + useStore.getState(), + [memberProjectRef], + ); + const confirmed = await api.dialogs.confirm( + latestProjectThreads.length > 0 + ? [ + `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ + latestProjectThreads.length === 1 ? "" : "s" + }?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel + ? [`Environment: ${member.environmentLabel}`] + : []), + "This permanently clears conversation history for those threads.", + "This removes only this project entry.", + "This action cannot be undone.", + ].join("\n") + : [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel + ? [`Environment: ${member.environmentLabel}`] + : []), + "This removes only this project entry.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + + await removeProject(member, { force: true }); + })().catch((error) => { + const message = + error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }); + }); + }, + }, }); return; } @@ -1310,23 +1391,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const memberProjectRef = scopeProjectRef(member.environmentId, member.id); - try { - const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(memberProjectRef); - const projectApi = readEnvironmentApi(member.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: member.id, - }); + await removeProject(member); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { @@ -1341,12 +1407,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); } }, - [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - getDraftThreadByProjectRef, - memberThreadCountByPhysicalKey, - ], + [memberThreadCountByPhysicalKey, removeProject], ); const handleProjectButtonContextMenu = useCallback( @@ -1429,8 +1490,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec buildTargetedItem("copy-path", "Copy Project Path"), buildTargetedItem("delete", "Remove project", { destructive: true, - isDisabled: (member) => - (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, }), ], { @@ -1449,7 +1508,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, - memberThreadCountByPhysicalKey, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, @@ -2660,48 +2718,32 @@ export default function Sidebar() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + // Build a mapping from physical project key → logical project key for // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { return buildPhysicalToLogicalProjectKeyMap({ - projects, + projects: orderedProjects, settings: projectGroupingSettings, }); - }, [projectGroupingSettings, projects]); + }, [orderedProjects, projectGroupingSettings]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( - projects.map((project) => [ + orderedProjects.map((project) => [ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), derivePhysicalProjectKey(project), ]), ), - [projects], - ); - - const sidebarProjects = useMemo(() => { - return buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => { - const rt = savedEnvironmentRuntimeById[environmentId]; - const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? null; - }, - }); - }, [ - projectGroupingSettings, - primaryEnvironmentId, - projects, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); - - const sidebarProjectByKey = useMemo( - () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), - [sidebarProjects], + [orderedProjects], ); const sidebarThreadByKey = useMemo( () => @@ -2728,6 +2770,30 @@ export default function Sidebar() { return physicalToLogicalKey.get(physicalKey) ?? physicalKey; }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); + }, [ + orderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { @@ -2813,7 +2879,11 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - reorderProjects([activeProject.projectKey], [overProject.projectKey]); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); + reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); @@ -2856,14 +2926,10 @@ export default function Sidebar() { [sidebarThreads], ); const sortedProjects = useMemo(() => { - const sortableProjects = orderItemsByPreferredIds({ - items: sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })), - preferredIds: projectOrder, - getId: (project) => project.id, - }); + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); const sortableThreads = visibleThreads.map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 37e0df1c..2df2e04f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -177,6 +177,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, { container: host }, ); @@ -196,6 +197,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, ); }, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 14f4f640..6c71e5eb 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, @@ -29,7 +30,12 @@ import { wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; import { + isDiffToggleShortcut, isTerminalClearShortcut, + isTerminalCloseShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -255,6 +261,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + keybindings: ResolvedKeybindingsConfig; } export function TerminalViewport({ @@ -271,6 +278,7 @@ export function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -282,6 +290,7 @@ export function TerminalViewport({ const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); + const keybindingsRef = useRef(keybindings); const lastAppliedTerminalEventIdRef = useRef(0); const terminalHydratedRef = useRef(false); const handleSessionExited = useEffectEvent(() => { @@ -292,6 +301,10 @@ export function TerminalViewport({ }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + useEffect(() => { + keybindingsRef.current = keybindings; + }, [keybindings]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -403,6 +416,18 @@ export function TerminalViewport({ }; terminal.attachCustomKeyEventHandler((event) => { + const currentKeybindings = keybindingsRef.current; + const options = { context: { terminalFocus: true, terminalOpen: true } }; + if ( + isTerminalToggleShortcut(event, currentKeybindings, options) || + isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalNewShortcut(event, currentKeybindings, options) || + isTerminalCloseShortcut(event, currentKeybindings, options) || + isDiffToggleShortcut(event, currentKeybindings, options) + ) { + return false; + } + const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); @@ -795,6 +820,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + keybindings: ResolvedKeybindingsConfig; } interface TerminalActionButtonProps { @@ -848,6 +874,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, + keybindings, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -1166,6 +1193,7 @@ export default function ThreadTerminalDrawer({ autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} />
@@ -1188,6 +1216,7 @@ export default function ThreadTerminalDrawer({ autoFocus resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} /> )} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 29bc06c2..60c0dcc7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -16,7 +16,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { forwardRef, memo, @@ -54,7 +54,6 @@ import { insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; -import { createModelSelection } from "../../modelSelectionUtils"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -71,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -160,6 +160,7 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; @@ -176,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop <> - + {props.showInteractionModeToggle ? ( + <> + - + + + ) : null} + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + ...(providerCard.provider === "opencode" + ? { serverUrl: event.target.value } + : {}), + }, + }, + }) + } + placeholder={providerCard.serverUrlPlaceholder} + spellCheck={false} + /> + {providerCard.serverUrlDescription ? ( + + {providerCard.serverUrlDescription} + + ) : null} + + + ) : null} + + {providerCard.serverPasswordPlaceholder ? ( +
+ +
+ ) : null} + {providerCard.homePathKey ? (
{Icon && (
{toast.actionProps && ( {toast.actionProps.children} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2169bbf8..48e5c1a1 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" | "copilot" | "claudeAgent" | "cursor" | "opencode", model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -561,6 +561,49 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftByKey(draftId)).toBeUndefined(); }); + it("revokes draft image blob URLs when clearing a project's draft thread", () => { + const store = useComposerDraftStore.getState(); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addImage(draftId, makeImage({ id: "img-project-clear", previewUrl: "blob:clear" })); + + store.clearProjectDraftThreadId(projectRef); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(revokeSpy).toHaveBeenCalledWith("blob:clear"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + + it("revokes draft image blob URLs when clearing a matching project draft thread by id", () => { + const store = useComposerDraftStore.getState(); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addImage( + draftId, + makeImage({ id: "img-project-clear-by-id", previewUrl: "blob:clear-by-id" }), + ); + + store.clearProjectDraftThreadById(projectRef, draftId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(revokeSpy).toHaveBeenCalledWith("blob:clear-by-id"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); @@ -959,6 +1002,79 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("keeps explicit Copilot reasoning overrides on the selection", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection( + threadRef, + modelSelection("copilot", "gpt-5", { reasoningEffort: "high" }), + ); + + store.setProviderModelOptions(threadRef, "copilot", { + reasoningEffort: "medium", + }); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.copilot).toEqual( + modelSelection("copilot", "gpt-5", { + reasoningEffort: "medium", + }), + ); + }); + + 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("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(); @@ -1131,6 +1247,41 @@ 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("stores sticky Copilot model selections", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelSelection( + modelSelection("copilot", "gpt-5", { + reasoningEffort: "medium", + }), + ); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.copilot).toEqual( + modelSelection("copilot", "gpt-5", { + reasoningEffort: "medium", + }), + ); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("copilot"); + }); + 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 34a59fde..7e378f7f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,11 @@ import { - CLAUDE_AGENT_EFFORT_OPTIONS, - CODEX_REASONING_EFFORT_OPTIONS, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, + type CursorModelOptions, + type CursorReasoningOption, + ClaudeAgentEffort, + CodexReasoningEffort, + type CopilotModelOptions, type EnvironmentId, ModelSelection, ProjectId, @@ -25,11 +29,10 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; -import { createModelSelection } from "./modelSelectionUtils"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { type TerminalContextDraft, @@ -104,9 +107,6 @@ const PersistedComposerThreadDraftState = Schema.Struct({ }); type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; -const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); -const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); - const LegacyCodexFields = Schema.Struct({ effort: Schema.optionalKey(CodexReasoningEffort), codexFastMode: Schema.optionalKey(Schema.Boolean), @@ -345,6 +345,7 @@ interface ComposerDraftStoreState { provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { + model?: string | null | undefined; persistSticky?: boolean; }, ) => void; @@ -531,7 +532,13 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "copilot" || value === "claudeAgent" ? value : null; + return value === "codex" || + value === "copilot" || + value === "claudeAgent" || + value === "cursor" || + value === "opencode" + ? value + : null; } function normalizeProviderModelOptions( @@ -544,16 +551,33 @@ function normalizeProviderModelOptions( candidate?.codex && typeof candidate.codex === "object" ? (candidate.codex as Record) : null; + const copilotCandidate = + candidate?.copilot && typeof candidate.copilot === "object" + ? (candidate.copilot as Record) + : null; const claudeCandidate = 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 openCodeCandidate = + candidate?.opencode && typeof candidate.opencode === "object" + ? (candidate.opencode as Record) + : null; + + const isCodexReasoningEffort = Schema.is(CodexReasoningEffort); + const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort); - const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) - ? codexCandidate.reasoningEffort + const codexCandidateReasoningEffort = codexCandidate?.reasoningEffort; + const legacyEffort = legacy?.effort; + const codexReasoningEffort = isCodexReasoningEffort(codexCandidateReasoningEffort) + ? codexCandidateReasoningEffort : provider === "codex" - ? Schema.is(CodexReasoningEffort)(legacy?.effort) - ? legacy.effort + ? isCodexReasoningEffort(legacyEffort) + ? legacyEffort : undefined : undefined; const codexFastMode = @@ -573,21 +597,15 @@ function normalizeProviderModelOptions( } : undefined; - const copilotReasoningEffort = - candidate?.copilot && - typeof candidate.copilot === "object" && - Schema.is(CodexReasoningEffort)((candidate.copilot as Record).reasoningEffort) - ? ((candidate.copilot as Record).reasoningEffort as - | "xhigh" - | "high" - | "medium" - | "low") - : provider === "copilot" - ? Schema.is(CodexReasoningEffort)(legacy?.effort) - ? (legacy.effort as "xhigh" | "high" | "medium" | "low") - : undefined - : undefined; - const copilot = + const copilotCandidateReasoningEffort = copilotCandidate?.reasoningEffort; + const copilotReasoningEffort = isCodexReasoningEffort(copilotCandidateReasoningEffort) + ? copilotCandidateReasoningEffort + : provider === "copilot" + ? isCodexReasoningEffort(legacyEffort) + ? legacyEffort + : undefined + : undefined; + const copilot: CopilotModelOptions | undefined = copilotReasoningEffort !== undefined ? { reasoningEffort: copilotReasoningEffort } : undefined; const claudeThinking = @@ -596,8 +614,9 @@ function normalizeProviderModelOptions( : claudeCandidate?.thinking === false ? false : undefined; - const claudeEffort = Schema.is(ClaudeAgentEffort)(claudeCandidate?.effort) - ? claudeCandidate.effort + const claudeCandidateEffort = claudeCandidate?.effort; + const claudeEffort = isClaudeAgentEffort(claudeCandidateEffort) + ? claudeCandidateEffort : undefined; const claudeFastMode = claudeCandidate?.fastMode === true @@ -622,13 +641,67 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !copilot && !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 + ? 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 + : undefined; + + const cursor: CursorModelOptions | undefined = + cursorCandidate !== null + ? (() => { + 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; + + const openCodeVariant = + typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 + ? openCodeCandidate.variant + : undefined; + const openCodeAgent = + typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 + ? openCodeCandidate.agent + : undefined; + const opencode = + openCodeVariant !== undefined || openCodeAgent !== undefined + ? { + ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), + ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), + } + : undefined; + + if (!codex && !copilot && !claude && cursor === undefined && !opencode) { return null; } return { ...(codex ? { codex } : {}), ...(copilot ? { copilot } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(cursor !== undefined ? { cursor } : {}), + ...(opencode ? { opencode } : {}), }; } @@ -659,17 +732,8 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = - provider === "codex" - ? modelOptions?.codex - : provider === "copilot" - ? modelOptions?.copilot - : modelOptions?.claudeAgent; - return createModelSelection({ - provider, - model, - ...(options ? { options } : {}), - }); + const options = modelOptions?.[provider]; + return createModelSelection(provider, model, options); } // ── Legacy sync helpers (used only during migration from v2 storage) ── @@ -682,11 +746,7 @@ function legacySyncModelSelectionOptions( return null; } const options = modelOptions?.[modelSelection.provider]; - return createModelSelection({ - provider: modelSelection.provider, - model: modelSelection.model, - ...(options ? { options } : {}), - }); + return createModelSelection(modelSelection.provider, modelSelection.model, options); } function legacyMergeModelSelectionIntoProviderModelOptions( @@ -730,17 +790,16 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "copilot", "claudeAgent"] as const) { + for (const provider of ["codex", "copilot", "claudeAgent", "cursor", "opencode"] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = createModelSelection({ + result[provider] = createModelSelection( provider, - model: - modelSelection?.provider === provider - ? modelSelection.model - : DEFAULT_MODEL_BY_PROVIDER[provider], + modelSelection?.provider === provider + ? modelSelection.model + : DEFAULT_MODEL_BY_PROVIDER[provider], options, - }); + ); } } } @@ -798,6 +857,15 @@ function revokeObjectPreviewUrl(previewUrl: string): void { URL.revokeObjectURL(previewUrl); } +function revokeDraftThreadPreviewUrls(draft: ComposerThreadDraftState | undefined): void { + if (!draft) { + return; + } + for (const image of draft.images) { + revokeObjectPreviewUrl(image.previewUrl); + } +} + function normalizePersistedAttachment(value: unknown): PersistedComposerImageAttachment | null { if (!value || typeof value !== "object") { return null; @@ -1117,7 +1185,8 @@ function removeDraftThreadReferences( ) as Record; const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = state.draftThreadsByThreadKey; - const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + const { [threadKey]: removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + revokeDraftThreadPreviewUrls(removedComposerDraft); return { draftsByThreadKey: restDraftsByThreadKey, draftThreadsByThreadKey: restDraftThreadsByThreadKey, @@ -2084,12 +2153,6 @@ const composerDraftStore = create()( if (threadKey.length === 0) { return; } - const existing = get().draftsByThreadKey[threadKey]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } set((state) => { const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; const hasLogicalProjectMapping = Object.values( @@ -2232,11 +2295,11 @@ const composerDraftStore = create()( nextMap[normalized.provider] = normalized; } else { // No options in selection → preserve existing options, update provider+model - nextMap[normalized.provider] = createModelSelection({ - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), - }); + nextMap[normalized.provider] = createModelSelection( + normalized.provider, + normalized.model, + current?.options, + ); } } const nextActiveProvider = normalized?.provider ?? base.activeProvider; @@ -2273,23 +2336,27 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "copilot", "claudeAgent"] as const) { + for (const provider of [ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + ] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { - nextMap[provider] = createModelSelection({ + nextMap[provider] = createModelSelection( provider, - model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], - options: opts, - }); + current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], + opts, + ); } else if (current?.options) { // Remove options but keep the selection - nextMap[provider] = createModelSelection({ - provider, - model: current.model, - }); + const { options: _, ...rest } = current; + nextMap[provider] = rest as ModelSelection; } } if (Equal.equals(base.modelSelectionByProvider, nextMap)) { @@ -2317,6 +2384,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 }, @@ -2332,16 +2402,14 @@ const composerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextMap[normalizedProvider] = createModelSelection({ - provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - options: providerOpts, - }); + nextMap[normalizedProvider] = createModelSelection( + normalizedProvider, + currentForProvider?.model ?? fallbackModel, + providerOpts, + ); } else if (currentForProvider?.options) { - nextMap[normalizedProvider] = createModelSelection({ - provider: normalizedProvider, - model: currentForProvider.model, - }); + const { options: _, ...rest } = currentForProvider; + nextMap[normalizedProvider] = rest as ModelSelection; } // Handle sticky persistence @@ -2352,21 +2420,16 @@ const composerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - createModelSelection({ - provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - }); + createModelSelection(normalizedProvider, fallbackModel); if (providerOpts) { - nextStickyMap[normalizedProvider] = createModelSelection({ - provider: normalizedProvider, - model: stickyBase.model, - options: providerOpts, - }); + nextStickyMap[normalizedProvider] = createModelSelection( + normalizedProvider, + stickyBase.model, + providerOpts, + ); } else if (stickyBase.options) { - nextStickyMap[normalizedProvider] = createModelSelection({ - provider: normalizedProvider, - model: stickyBase.model, - }); + const { options: _, ...rest } = stickyBase; + nextStickyMap[normalizedProvider] = rest as ModelSelection; } nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; } diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index f0781294..e75fb614 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -95,9 +95,7 @@ describe("environment runtime catalog stores", () => { }); it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - let resolveRegistryRead: () => void = () => { - throw new Error("Registry read resolver was not initialized."); - }; + let resolveRegistryRead: (() => void) | undefined; vi.stubGlobal("window", { nativeApi: { @@ -133,7 +131,7 @@ describe("environment runtime catalog stores", () => { useSavedEnvironmentRegistryStore.getState().upsert(record); - resolveRegistryRead(); + resolveRegistryRead?.(); await hydrationPromise; expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); diff --git a/apps/web/src/environments/runtime/service.test.ts b/apps/web/src/environments/runtime/service.test.ts index a6aaab26..7a4af404 100644 --- a/apps/web/src/environments/runtime/service.test.ts +++ b/apps/web/src/environments/runtime/service.test.ts @@ -1,48 +1,6 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { syncProjects, type UiState } from "~/uiStateStore"; -import type { Project } from "~/types"; -import { buildProjectUiSyncInputs, shouldApplyTerminalEvent } from "./service"; - -const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make("env-local"); -const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("env-remote"); - -function makeProject( - input: Partial & Pick, -): Project { - return { - id: input.id, - environmentId: input.environmentId, - cwd: input.cwd, - name: input.name ?? "project", - createdAt: input.createdAt ?? "2026-04-17T00:00:00.000Z", - updatedAt: input.updatedAt ?? "2026-04-17T00:00:00.000Z", - repositoryIdentity: input.repositoryIdentity ?? { - canonicalKey: "github.com/t3tools/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/t3tools/repo.git", - }, - displayName: "t3code-copilot", - name: "t3code-copilot", - rootPath: "/repo", - }, - defaultModelSelection: input.defaultModelSelection ?? null, - scripts: input.scripts ?? [], - }; -} - -function makeUiState(input?: Partial): UiState { - return { - projectExpandedById: {}, - projectOrder: [], - threadLastVisitedAtById: {}, - threadChangedFilesExpandedById: {}, - ...input, - }; -} +import { shouldApplyTerminalEvent } from "./service"; describe("shouldApplyTerminalEvent", () => { it("applies terminal events for draft-only threads", () => { @@ -81,76 +39,3 @@ describe("shouldApplyTerminalEvent", () => { ).toBe(true); }); }); - -describe("buildProjectUiSyncInputs", () => { - it("uses logical project keys so grouped rows keep stable expansion state", () => { - const localProject = makeProject({ - id: ProjectId.make("project-local"), - environmentId: PRIMARY_ENVIRONMENT_ID, - cwd: "/repo", - }); - const remoteProject = makeProject({ - id: ProjectId.make("project-remote"), - environmentId: REMOTE_ENVIRONMENT_ID, - cwd: "/repo", - }); - - const initialState = makeUiState({ - projectExpandedById: { - "github.com/t3tools/repo": false, - }, - projectOrder: ["github.com/t3tools/repo"], - }); - - const next = syncProjects( - initialState, - buildProjectUiSyncInputs([localProject, remoteProject]), - ); - - expect(next.projectOrder).toEqual(["github.com/t3tools/repo"]); - expect(next.projectExpandedById["github.com/t3tools/repo"]).toBe(false); - }); - - it("deduplicates grouped projects before syncing ui state", () => { - const localProject = makeProject({ - id: ProjectId.make("project-local"), - environmentId: PRIMARY_ENVIRONMENT_ID, - cwd: "/repo", - }); - const remoteProject = makeProject({ - id: ProjectId.make("project-remote"), - environmentId: REMOTE_ENVIRONMENT_ID, - cwd: "/repo", - name: "Remote rename", - }); - - expect(buildProjectUiSyncInputs([localProject, remoteProject])).toEqual([ - { - key: "github.com/t3tools/repo", - logicalId: "github.com/t3tools/repo", - cwd: "/repo", - }, - ]); - }); - - it("selects a deterministic cwd for grouped rows before dedupe", () => { - const remoteProject = makeProject({ - id: ProjectId.make("project-remote"), - environmentId: REMOTE_ENVIRONMENT_ID, - cwd: "/repo-z", - }); - const localProject = makeProject({ - id: ProjectId.make("project-local"), - environmentId: PRIMARY_ENVIRONMENT_ID, - cwd: "/repo-a", - }); - - expect(buildProjectUiSyncInputs([remoteProject, localProject])).toEqual([ - { - key: "github.com/t3tools/repo", - logicalId: "github.com/t3tools/repo", - cwd: "/repo-a", - }, - ]); - }); -}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 8ab65e2a..cee9525c 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -14,6 +14,7 @@ import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, scopedThreadKey, + scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; @@ -22,7 +23,6 @@ import { markPromotedDraftThreadsByRef, useComposerDraftStore, } from "~/composerDraftStore"; -import { getUnifiedSettingsSnapshot } from "~/hooks/useSettings"; import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; @@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; -import { deriveLogicalProjectKeyFromSettings } from "../../logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, +} from "../../logicalProject"; +import { getClientSettingsSnapshot } from "../../hooks/useSettings"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -466,50 +470,19 @@ function coalesceOrchestrationUiEvents( return coalesced; } -export function buildProjectUiSyncInputs( - projects: ReturnType, -) { - const projectGroupingSettings = getUnifiedSettingsSnapshot(); - const logicalProjectInputs = projects.map((project, index) => ({ - key: deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - logicalId: deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - cwd: project.cwd, - incomingIndex: index, - })); - logicalProjectInputs.sort((left, right) => { - const byLogicalId = left.logicalId.localeCompare(right.logicalId); - if (byLogicalId !== 0) { - return byLogicalId; - } - const byCwd = left.cwd.localeCompare(right.cwd); - if (byCwd !== 0) { - return byCwd; - } - return left.incomingIndex - right.incomingIndex; - }); - - const inputsByLogicalProjectKey = new Map< - string, - { key: string; logicalId: string; cwd: string; incomingIndex: number } - >(); - for (const input of logicalProjectInputs) { - if (!inputsByLogicalProjectKey.has(input.logicalId)) { - inputsByLogicalProjectKey.set(input.logicalId, input); - } - } - - return [...inputsByLogicalProjectKey.values()] - .toSorted((left, right) => left.incomingIndex - right.incomingIndex) - .map(({ key, logicalId, cwd }) => ({ - key, - logicalId, - cwd, - })); -} - function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncProjects(buildProjectUiSyncInputs(projects)); + const clientSettings = getClientSettingsSnapshot(); + useUiStateStore.getState().syncProjects( + projects.map((project) => ({ + key: derivePhysicalProjectKey(project), + logicalId: deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, + }), + cwd: project.cwd, + })), + ); } function syncThreadUiFromStore() { @@ -577,7 +550,17 @@ function applyRecoveredEventBatch( useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjectsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncProjects(buildProjectUiSyncInputs(projects)); + const clientSettings = getClientSettingsSnapshot(); + useUiStateStore.getState().syncProjects( + projects.map((project) => ({ + key: derivePhysicalProjectKey(project), + logicalId: deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, + }), + cwd: project.cwd, + })), + ); } const needsThreadUiSync = events.some( @@ -603,6 +586,11 @@ function applyRecoveredEventBatch( .getState() .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); } + for (const event of events) { + if (event.type === "project.deleted") { + draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); + } + } for (const threadId of batchEffects.removeTerminalStateThreadIds) { useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); } diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4a169d09..d512b6c7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -148,10 +148,6 @@ export function useNewThreadHandler() { export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); - const projectGroupingSettings = useSettings((settings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - })); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -173,9 +169,9 @@ export function useHandleNewThread() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); - }, [projectGroupingSettings, projectOrder, projects]); + }, [projectOrder, projects]); const handleNewThread = useNewThreadState(); return { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 8c4c54fa..3ff433a8 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -38,14 +38,7 @@ function emitClientSettingsChange() { function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot; } - -export function getUnifiedSettingsSnapshot(): UnifiedSettings { - return { - ...DEFAULT_UNIFIED_SETTINGS, - ...getServerConfig()?.settings, - ...getClientSettingsSnapshot(), - }; -} +export { getClientSettingsSnapshot }; function replaceClientSettingsSnapshot(settings: ClientSettings): void { clientSettingsSnapshot = settings; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 2c968e6b..683afbbf 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -131,6 +131,15 @@ describe("isTerminalToggleShortcut", () => { isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }), ); }); + + it("matches Ctrl+J on non-macOS while terminalFocus is true", () => { + assert.isTrue( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); }); describe("split/new/close terminal shortcuts", () => { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 10e71a0f..4258ccb3 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -215,7 +215,6 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], - quotaSnapshots: [], slashCommands: [], skills: [], }, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 8dbcc6a6..8ea0f059 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -1,10 +1,15 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + GIT_TEXT_GENERATION_PROVIDERS, type ModelSelection, type ProviderKind, type ServerProvider, } from "@t3tools/contracts"; -import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; +import { + createModelSelection, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { @@ -12,7 +17,6 @@ import { getProviderModels, resolveSelectableProvider, } from "./providerModels"; -import { createModelSelection } from "./modelSelectionUtils"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; @@ -53,6 +57,20 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record, - requestedProvider: ProviderKind, - allowedProviders?: ReadonlyArray, -): ProviderKind { - if (!allowedProviders || allowedProviders.includes(requestedProvider)) { - return resolveSelectableProvider(providers, requestedProvider); - } - - const firstAllowedEnabled = allowedProviders.find( - (provider) => providers.find((candidate) => candidate.provider === provider)?.enabled ?? true, - ); - return resolveSelectableProvider( - providers, - firstAllowedEnabled ?? allowedProviders[0] ?? requestedProvider, - ); -} - export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, @@ -209,11 +221,20 @@ export function resolveAppModelSelectionState( provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = resolveAllowedProvider(providers, selection.provider, allowedProviders); + const resolvedRequestedProvider = + allowedProviders && !allowedProviders.includes(selection.provider) + ? (allowedProviders.find( + (provider) => + providers.find((candidate) => candidate.provider === provider)?.enabled ?? true, + ) ?? + allowedProviders[0] ?? + GIT_TEXT_GENERATION_PROVIDERS[0]) + : selection.provider; + const provider = resolveSelectableProvider(providers, resolvedRequestedProvider); // When the provider changed due to fallback (e.g. selected provider was disabled), // don't carry over the old provider's model — use the fallback provider's default. - const selectedModel = provider === selection.provider ? selection.model : null; + const selectedModel = provider === resolvedRequestedProvider ? selection.model : null; const model = resolveAppModelSelection(provider, settings, providers, selectedModel); const { modelOptionsForDispatch } = getComposerProviderState({ provider, @@ -221,13 +242,9 @@ export function resolveAppModelSelectionState( models: getProviderModels(providers, provider), prompt: "", modelOptions: { - [provider]: provider === selection.provider ? selection.options : undefined, + [provider]: provider === resolvedRequestedProvider ? selection.options : undefined, }, }); - return createModelSelection({ - provider, - model, - ...(modelOptionsForDispatch !== undefined ? { options: modelOptionsForDispatch } : {}), - }); + return createModelSelection(provider, model, modelOptionsForDispatch); } diff --git a/apps/web/src/modelSelectionUtils.ts b/apps/web/src/modelSelectionUtils.ts deleted file mode 100644 index 6e9edf46..00000000 --- a/apps/web/src/modelSelectionUtils.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - type ClaudeModelOptions, - type CodexModelOptions, - type CopilotModelOptions, - type ModelSelection, - type ProviderKind, - type ProviderModelOptions, -} from "@t3tools/contracts"; - -type ModelSelectionByProvider = { - codex: Extract; - copilot: Extract; - claudeAgent: Extract; -}; - -export type ModelSelectionOptionsForProvider

= - ModelSelectionByProvider[P]["options"]; - -export function getProviderModelOptions

( - provider: P, - options: ProviderModelOptions | null | undefined, -): ModelSelectionOptionsForProvider

| undefined { - if (provider === "codex") { - return options?.codex as ModelSelectionOptionsForProvider

| undefined; - } - if (provider === "copilot") { - return options?.copilot as ModelSelectionOptionsForProvider

| undefined; - } - return options?.claudeAgent as ModelSelectionOptionsForProvider

| undefined; -} - -export function createModelSelection

(input: { - provider: P; - model: string; - options?: ModelSelectionOptionsForProvider

; -}): ModelSelectionByProvider[P] { - if (input.provider === "codex") { - return { - provider: "codex", - model: input.model, - ...(input.options !== undefined ? { options: input.options as CodexModelOptions } : {}), - } as ModelSelectionByProvider[P]; - } - if (input.provider === "copilot") { - return { - provider: "copilot", - model: input.model, - ...(input.options !== undefined ? { options: input.options as CopilotModelOptions } : {}), - } as ModelSelectionByProvider[P]; - } - return { - provider: "claudeAgent", - model: input.model, - ...(input.options !== undefined ? { options: input.options as ClaudeModelOptions } : {}), - } as ModelSelectionByProvider[P]; -} diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 298c794d..1952dc12 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -5,7 +5,10 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + normalizeModelSlug, + normalizeProviderModelOptionsWithCapabilities, +} from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -33,7 +36,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( @@ -67,3 +73,5 @@ export function getDefaultServerModel( DEFAULT_MODEL_BY_PROVIDER[provider] ); } + +export { normalizeProviderModelOptionsWithCapabilities }; diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 482a56b6..64b5e7bc 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { RotateCcwIcon } from "lucide-react"; -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; @@ -7,11 +7,27 @@ import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { isElectron } from "../env"; +function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); + + return ( + + ); +} + function SettingsContentLayout() { + const location = useLocation(); const [restoreSignal, setRestoreSignal] = useState(0); - const { changedSettingLabels, restoreDefaults } = useSettingsRestore(() => - setRestoreSignal((value) => value + 1), - ); + const showRestoreDefaults = location.pathname === "/settings/general"; + const handleRestored = () => setRestoreSignal((value) => value + 1); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -33,20 +49,14 @@ function SettingsContentLayout() {

{!isElectron && (
-
+
Settings -
- -
+ {showRestoreDefaults ? ( +
+ +
+ ) : null}
)} @@ -56,17 +66,11 @@ function SettingsContentLayout() { Settings -
- -
+ {showRestoreDefaults ? ( +
+ +
+ ) : null}
)} diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index a6d5c38e..a587fcd9 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -48,7 +48,6 @@ const defaultProviders: ReadonlyArray = [ auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], - quotaSnapshots: [], slashCommands: [], skills: [], }, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 12774906..baf384d6 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,7 +11,6 @@ import { deriveCompletionDividerBeforeEntryId, deriveActiveWorkStartedAt, deriveActivePlanState, - PROVIDER_OPTIONS, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, @@ -921,6 +920,199 @@ 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("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({ + 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({ @@ -1324,32 +1516,3 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); }); - -describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { - const copilot = PROVIDER_OPTIONS.find((option) => option.value === "copilot"); - 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: "copilot", label: "GitHub Copilot", available: true }, - { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, - ]); - expect(copilot).toEqual({ - value: "copilot", - label: "GitHub Copilot", - available: true, - }); - expect(claude).toEqual({ - value: "claudeAgent", - label: "Claude", - available: true, - }); - expect(cursor).toEqual({ - value: "cursor", - label: "Cursor", - available: false, - }); - }); -}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index df71a3bd..2a71e087 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, @@ -20,7 +22,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "cursor"; +export type ProviderPickerKind = ProviderKind; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -30,7 +32,8 @@ export const PROVIDER_OPTIONS: Array<{ { value: "codex", label: "Codex", available: true }, { value: "copilot", label: "GitHub Copilot", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "opencode", label: "OpenCode", available: true }, + { value: "cursor", label: "Cursor", available: true }, ]; export interface WorkLogEntry { @@ -50,6 +53,7 @@ export interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + toolCallId?: string; } export interface PendingApproval { @@ -352,12 +356,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 = - (latestTurnId - ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1) - : undefined) ?? - allPlanActivities.at(-1) ?? - null; + const latest = Option.firstSomeOf([ + ...(latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); if (!latest) { return null; } @@ -515,6 +519,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; + const detail = isTaskActivity + ? !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ? stripTrailingExitCode(payload.detail).output + : null + : extractToolDetail(payload, title ?? activity.summary); + const toolCallId = isTaskActivity ? null : extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -529,16 +542,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if ( - !taskDetailAsLabel && - 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 (commandPreview.command) { entry.command = commandPreview.command; @@ -558,6 +563,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; @@ -593,7 +601,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( @@ -608,6 +625,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, @@ -619,6 +637,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolCallId ? { toolCallId } : {}), }; } @@ -637,6 +656,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 ?? ""; @@ -674,6 +696,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 trimMatchingOuterQuotes(value: string): string { const trimmed = value.trim(); if ( @@ -857,6 +883,111 @@ 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 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, +): 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; + } + + if (isCommandToolDetail(payload, heading)) { + return null; + } + + 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.test.ts b/apps/web/src/store.test.ts index 9bb01ba0..8afbe689 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -365,6 +365,38 @@ describe("thread selection memoization", () => { ).toBe(false); expect(selectThreadExistsByRef(state, null)).toBe(false); }); + it("reuses derived threads when shell modelSelection objects are recreated with equal values", () => { + const thread = makeThread({ + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", fastMode: true }, + }, + updatedAt: "2026-02-13T00:00:00.000Z", + }); + const ref = scopeThreadRef(thread.environmentId, thread.id); + const firstState = makeState(thread); + const secondState = applyOrchestrationEvent( + firstState, + makeEvent("thread.meta-updated", { + threadId: thread.id, + title: thread.title, + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "max", fastMode: true }, + }, + updatedAt: thread.updatedAt ?? thread.createdAt, + }), + thread.environmentId, + ); + + const first = selectThreadByRef(firstState, ref); + const second = selectThreadByRef(secondState, ref); + + expect(secondState).toBe(firstState); + expect(second).toBe(first); + }); }); describe("setThreadBranch", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 018752ce..028b08f2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,4 @@ -import { +import type { EnvironmentId, MessageId, OrchestrationCheckpointSummary, @@ -15,13 +15,12 @@ import { OrchestrationThreadShell, OrchestrationThreadActivity, ProjectId, - ProviderKind, ScopedProjectRef, ScopedThreadRef, - ThreadId, - TurnId, } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; +import { ProviderKind } from "@t3tools/contracts"; +import type { ThreadId, TurnId } from "@t3tools/contracts"; +import { Equal, Schema } from "effect"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { @@ -130,9 +129,9 @@ function arraysEqual(left: readonly T[], right: readonly T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } -function normalizeModelSelection< - T extends { provider: ProviderKind; model: string } & Record, ->(selection: T): T { +function normalizeModelSelection( + selection: T, +): T { return { ...selection, model: resolveModelSlugForProvider(selection.provider, selection.model), @@ -378,6 +377,19 @@ function threadSessionsEqual( ); } +function modelSelectionsEqual( + left: ThreadShell["modelSelection"] | undefined, + right: ThreadShell["modelSelection"] | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return ( + left.provider === right.provider && + left.model === right.model && + Equal.equals(left.options, right.options) + ); +} + function sidebarThreadSummariesEqual( left: SidebarThreadSummary | undefined, right: SidebarThreadSummary, @@ -410,7 +422,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b left.codexThreadId === right.codexThreadId && left.projectId === right.projectId && left.title === right.title && - left.modelSelection === right.modelSelection && + modelSelectionsEqual(left.modelSelection, right.modelSelection) && left.runtimeMode === right.runtimeMode && left.interactionMode === right.interactionMode && left.error === right.error && @@ -1003,7 +1015,7 @@ function toLegacySessionStatus( function toLegacyProvider(providerName: string | null): ProviderKind { if (Schema.is(ProviderKind)(providerName)) { - return providerName as ProviderKind; + return providerName; } return "codex"; } diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 78d5a4e0..1d0dd00d 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,5 +1,5 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { clearThreadUi, @@ -12,6 +12,11 @@ import { type UiState, } from "./uiStateStore"; +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, @@ -297,6 +302,50 @@ describe("uiStateStore pure functions", () => { expect(next.projectOrder).toEqual([recreatedProjectKeyB, recreatedProjectKeyA]); }); + it("useUiStateStore preserves all-collapsed project state across reload", async () => { + vi.useFakeTimers(); + const storage = new Map(); + const localStorage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + }, + } satisfies Storage; + const testWindow = { + localStorage, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; + vi.stubGlobal("window", testWindow); + vi.stubGlobal("localStorage", localStorage); + + vi.resetModules(); + const firstModule = await import("./uiStateStore"); + firstModule.useUiStateStore + .getState() + .syncProjects([{ key: "project-1", cwd: "/tmp/project-1" }]); + firstModule.useUiStateStore.getState().setProjectExpanded("project-1", false); + vi.advanceTimersByTime(600); + + vi.resetModules(); + const secondModule = await import("./uiStateStore"); + secondModule.useUiStateStore + .getState() + .syncProjects([{ key: "project-1", cwd: "/tmp/project-1" }]); + + expect(secondModule.useUiStateStore.getState().projectExpandedById["project-1"]).toBe(false); + }); + it("syncProjects returns a new state when only project cwd changes", () => { const project1 = ProjectId.make("project-1"); const initialState = syncProjects( diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 96760e63..ec6c2c5d 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -16,6 +16,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ ] as const; interface PersistedUiState { + hasExpandedProjectState?: boolean; expandedProjectLogicalIds?: string[]; expandedProjectCwds?: string[]; projectOrderLogicalIds?: string[]; @@ -46,6 +47,13 @@ export interface SyncThreadInput { seedVisitedAt?: string | undefined; } +const initialState: UiState = { + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, +}; + function appendUniqueString(target: string[], seen: Set, value: string | undefined): void { if (!value || value.length === 0 || seen.has(value)) { return; @@ -54,13 +62,6 @@ function appendUniqueString(target: string[], seen: Set, value: string | target.push(value); } -const initialState: UiState = { - projectExpandedById: {}, - projectOrder: [], - threadLastVisitedAtById: {}, - threadChangedFilesExpandedById: {}, -}; - const persistedExpandedProjectLogicalIds = new Set(); const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderLogicalIds: string[] = []; @@ -68,6 +69,7 @@ const persistedProjectOrderCwds: string[] = []; const currentProjectCwdById = new Map(); const currentProjectLogicalIdById = new Map(); let legacyKeysCleanedUp = false; +let hasPersistedExpandedProjectState = false; function readPersistedState(): UiState { if (typeof window === "undefined") { @@ -128,6 +130,10 @@ function sanitizePersistedThreadChangedFilesExpanded( } function hydratePersistedProjectState(parsed: PersistedUiState): void { + hasPersistedExpandedProjectState = + parsed.hasExpandedProjectState === true || + parsed.expandedProjectLogicalIds !== undefined || + parsed.expandedProjectCwds !== undefined; persistedExpandedProjectLogicalIds.clear(); persistedExpandedProjectCwds.clear(); persistedProjectOrderLogicalIds.length = 0; @@ -206,6 +212,7 @@ function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ + hasExpandedProjectState: true, expandedProjectLogicalIds, expandedProjectCwds, projectOrderLogicalIds, @@ -308,11 +315,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput ? previousExpandedById[previousProjectIdForLogicalId] : undefined) ?? (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? - (persistedExpandedProjectLogicalIds.size > 0 - ? persistedExpandedProjectLogicalIds.has(logicalId) - : persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.cwd) - : true); + (hasPersistedExpandedProjectState + ? persistedExpandedProjectLogicalIds.has(logicalId) || + (persistedExpandedProjectLogicalIds.size === 0 && + persistedExpandedProjectCwds.has(project.cwd)) + : true); nextExpandedById[project.key] = expanded; return { id: project.key, diff --git a/bun.lock b/bun.lock index e8d18df5..af5a346e 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:", @@ -52,6 +53,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@github/copilot-sdk": "^0.2.2", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", @@ -65,6 +67,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "effect-acp": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:", @@ -149,6 +152,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", @@ -198,6 +216,7 @@ "catalog": { "@effect/atom-react": "4.0.0-beta.45", "@effect/language-service": "0.84.2", + "@effect/openapi-generator": "4.0.0-beta.45", "@effect/platform-bun": "4.0.0-beta.45", "@effect/platform-node": "4.0.0-beta.45", "@effect/platform-node-shared": "4.0.0-beta.45", @@ -211,7 +230,7 @@ "vitest": "^4.0.0", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -299,6 +318,8 @@ "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], + "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.45", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.45", "effect": "^4.0.0-beta.45" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-zT1UPzapV6mZebMuYhoDPQj/FKNn4R+oDgz1hEcQwazal4+lVDyEYGhRl0PgZroy6CP78fAOWNgW7TriGqSlOQ=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-dZsbkJ+o4JfKE1OIAlMq8YWhmEfXxtTYennYADK/M/XlOtkFLsc2jkZppDvMznVni6JpFxWc7wd5rUf8jBVP5g=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45", "ioredis": "^5.7.0" } }, "sha512-P07NMG4eoy62iLX9szak0hkr7hrwgq5+XMkm7URVug/3zqfZVxEG2kxn9JzVK1lF5WbaVrEKwjt3IkEJxzSPtg=="], @@ -395,21 +416,21 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], - "@github/copilot": ["@github/copilot@1.0.31", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.31", "@github/copilot-darwin-x64": "1.0.31", "@github/copilot-linux-arm64": "1.0.31", "@github/copilot-linux-x64": "1.0.31", "@github/copilot-win32-arm64": "1.0.31", "@github/copilot-win32-x64": "1.0.31" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-AfoVW9pHsKQGtLCpPcvQ8TOwBVF8meo5srle/8cqRSsx882CpIQx5C4uNs6zwrCtqMTo8M8D6zlDIbXkLudrXw=="], + "@github/copilot": ["@github/copilot@1.0.32", "", { "optionalDependencies": { "@github/copilot-darwin-arm64": "1.0.32", "@github/copilot-darwin-x64": "1.0.32", "@github/copilot-linux-arm64": "1.0.32", "@github/copilot-linux-x64": "1.0.32", "@github/copilot-win32-arm64": "1.0.32", "@github/copilot-win32-x64": "1.0.32" }, "bin": { "copilot": "npm-loader.js" } }, "sha512-ydEYAztJQa1sLQw+WPmnkkt3Sf/k2Smn/7szzYvt1feUOdNIak1gHpQhKcgPr2w252gjVLRWjOiynoeLVW0Fbw=="], - "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.31", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-DnAbe87U55/egBu/SFdMniQfhnYjfP3ZXXhrba3DZMXQI+91iRAGfPFKAsSlekl0zfNFw8toOkiafr9Hu2lHvA=="], + "@github/copilot-darwin-arm64": ["@github/copilot-darwin-arm64@1.0.32", "", { "os": "darwin", "cpu": "arm64", "bin": { "copilot-darwin-arm64": "copilot" } }, "sha512-RtGHpnrbP1eVtpzitLqC0jkBlo63PJiByv6W/NTtLw4ZAllumb5kMk8JaTtydKl9DCOHA0wfXbG5/JkGXuQ81g=="], - "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.31", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-mFmuYT3N1JE3zRIwCAPaXGDstL8Npa62Jey3vT4Lo003NfzQrBzvZ4ObAVMTmFQ6pRZzj39rTTKp1vLYGg+K0w=="], + "@github/copilot-darwin-x64": ["@github/copilot-darwin-x64@1.0.32", "", { "os": "darwin", "cpu": "x64", "bin": { "copilot-darwin-x64": "copilot" } }, "sha512-eyF6uy8gcZ4m/0UdM9UoykMDotZ8hZPJ1xIg0iHy4wrNtkYOaAspAoVpOkm50ODOQAHJ5PVV+9LuT6IoeL+wHQ=="], - "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.31", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-R5V7EIqn92f9YMe3zbQkW++Mw8WErDy6hA8Rr95bSJGiTVyWdj5kqPWSAPH6MLjFbC1T5cJQm/1we+QP3XO3Cw=="], + "@github/copilot-linux-arm64": ["@github/copilot-linux-arm64@1.0.32", "", { "os": "linux", "cpu": "arm64", "bin": { "copilot-linux-arm64": "copilot" } }, "sha512-acRAu5ehFPnw3hQSIxcmi7wzv8PAYd+nqdxZXizOi++en3QWgez7VEXiKLe9Ukf50iiGReg19yvWV4iDOGC0HQ=="], - "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.31", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-LmcCGmYP9QLim/YMu5e1UlVeqCt/cuMI0fIqkdHs68h+0FGreSnHpn7nA9RbjAbQuPq9HFWeFjG5UpbAHM71Xg=="], + "@github/copilot-linux-x64": ["@github/copilot-linux-x64@1.0.32", "", { "os": "linux", "cpu": "x64", "bin": { "copilot-linux-x64": "copilot" } }, "sha512-lw86YDwkTKwmeVpfnPErDe9DhemrOHN+l92xOU9wQSH5/d+HguXwRb3e4cQjlxsGLS+/fWRGtwf+u2fbQ37avw=="], "@github/copilot-sdk": ["@github/copilot-sdk@0.2.2", "", { "dependencies": { "@github/copilot": "^1.0.21", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" } }, "sha512-VZCqS08YlUM90bUKJ7VLeIxgTTEHtfXBo84T1IUMNvXRREX2csjPH6Z+CPw3S2468RcCLvzBXcc9LtJJTLIWFw=="], - "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.31", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-OlMPsQYFbl1hzrE0t703BwB9k8lQauQ4ETiiKpXSV4FxUb3DAU9PqWcy1pZoBjmLCni9h1ASQQKmPQ9ERJPm3g=="], + "@github/copilot-win32-arm64": ["@github/copilot-win32-arm64@1.0.32", "", { "os": "win32", "cpu": "arm64", "bin": { "copilot-win32-arm64": "copilot.exe" } }, "sha512-+eZpuzgBbLHMIzltH541wfbbMy0HEdG91ISzRae3qPCssf3Ad85sat6k7FWTRBSZBFrN7z4yMQm5gROqDJYGSA=="], - "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.31", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-nK8uRdlKH6TNk1cjBqEPTvzWQxwnDPgNN3M5bB7TBXL6EsaFdUJePz4tqutUPoPbSKQqo+DtmJGT3/+A30ZcXg=="], + "@github/copilot-win32-x64": ["@github/copilot-win32-x64@1.0.32", "", { "os": "win32", "cpu": "x64", "bin": { "copilot-win32-x64": "copilot.exe" } }, "sha512-R6SW1dsEVmPMhrN/WRTetS4gVxcuYcxi2zfDPOfcjW3W0iD0Vwpt3MlqwBaU2UL36j+rnTnmiOA+g82FIBCYVg=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -555,6 +576,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], @@ -765,9 +788,7 @@ "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], - "@tanstack/router-core": ["@tanstack/router-core@1.168.15", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA=="], - - "@tanstack/router-generator": ["@tanstack/router-generator@1.166.32", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "magic-string": "^0.30.21", "prettier": "^3.5.0", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-VuusKwEXcgKq+myq1JQfZogY8scTXIIeFls50dJ/UXgCXWp5n14iFreYNlg41wURcak2oA3M+t2TVfD0xUUD6g=="], + "@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="], "@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.22", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.15", "@tanstack/router-generator": "1.166.32", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.21", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-wYPzIvBK8bcmXVUpZfSgGBXOrfBAdF4odKevz6rejio5rEd947NtKDF5R7eYdwlAOmRqYpLJnJ1QHkc5t8bY4w=="], @@ -775,19 +796,7 @@ "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], - "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], - - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], - - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], - - "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], - - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], - - "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], - - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -799,7 +808,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -913,7 +922,9 @@ "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], - "astro": ["astro@6.1.7", "", { "dependencies": { "@astrojs/compiler": "^3.0.1", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.1.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-pvZysIUV2C2nRv8N7cXAkCLcfDQz/axAxF09SqiTz1B+xnvbhy6KzL2I6J15ZBXk8k0TfMD75dJ151QyQmAqZA=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "astro": ["astro@6.0.5", "", { "dependencies": { "@astrojs/compiler": "^3.0.0", "@astrojs/internal-helpers": "0.8.0", "@astrojs/markdown-remark": "7.0.0", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", "devalue": "^5.6.3", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyclip": "^0.1.6", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^7.3.1", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "bin/astro.mjs" } }, "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -943,7 +954,7 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -959,7 +970,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -1015,6 +1026,12 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -1047,7 +1064,7 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], @@ -1087,6 +1104,8 @@ "effect": ["effect@4.0.0-beta.45", "", { "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-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="], + "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.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], @@ -1127,6 +1146,8 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -1249,7 +1270,7 @@ "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], - "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -1279,6 +1300,10 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1315,6 +1340,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1615,6 +1642,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], @@ -1639,6 +1668,8 @@ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], @@ -1649,6 +1680,10 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], @@ -1743,6 +1778,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1863,6 +1902,8 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -2093,7 +2134,7 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], diff --git a/package.json b/package.json index 9e412b5b..93a75d5b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "catalog": { "effect": "4.0.0-beta.45", "@effect/atom-react": "4.0.0-beta.45", + "@effect/openapi-generator": "4.0.0-beta.45", "@effect/platform-bun": "4.0.0-beta.45", "@effect/platform-node": "4.0.0-beta.45", "@effect/platform-node-shared": "4.0.0-beta.45", diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts new file mode 100644 index 00000000..74393427 --- /dev/null +++ b/packages/contracts/src/model.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { GIT_TEXT_GENERATION_PROVIDERS } from "./model.ts"; + +describe("GIT_TEXT_GENERATION_PROVIDERS", () => { + it("includes current direct git text generation providers and excludes copilot", () => { + expect(GIT_TEXT_GENERATION_PROVIDERS).toEqual(["codex", "claudeAgent", "opencode", "cursor"]); + }); +}); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 7bc9e37f..c1ca2658 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -3,7 +3,8 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import type { ProviderKind } from "./orchestration.ts"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; -export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; +export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); +export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; export const CLAUDE_AGENT_EFFORT_OPTIONS = [ "low", "medium", @@ -12,32 +13,56 @@ export const CLAUDE_AGENT_EFFORT_OPTIONS = [ "max", "ultrathink", ] as const; -export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; -export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort; +export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); +export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; +export type ClaudeCodeEffort = ClaudeAgentEffort; +export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; +export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS); +export type CursorReasoningOption = typeof CursorReasoningOption.Type; + +export type ProviderReasoningEffort = + | CodexReasoningEffort + | ClaudeAgentEffort + | CursorReasoningOption; export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + reasoningEffort: Schema.optional(CodexReasoningEffort), fastMode: Schema.optional(Schema.Boolean), }); export type CodexModelOptions = typeof CodexModelOptions.Type; export const CopilotModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + reasoningEffort: Schema.optional(CodexReasoningEffort), }); export type CopilotModelOptions = typeof CopilotModelOptions.Type; export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS)), + effort: Schema.optional(ClaudeAgentEffort), fastMode: Schema.optional(Schema.Boolean), contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const CursorModelOptions = Schema.Struct({ + reasoning: Schema.optional(CursorReasoningOption), + fastMode: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Boolean), + contextWindow: Schema.optional(Schema.String), +}); +export type CursorModelOptions = typeof CursorModelOptions.Type; +export const OpenCodeModelOptions = Schema.Struct({ + variant: Schema.optional(TrimmedNonEmptyString), + agent: Schema.optional(TrimmedNonEmptyString), +}); +export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), copilot: Schema.optional(CopilotModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + cursor: Schema.optional(CursorModelOptions), + opencode: Schema.optional(OpenCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -61,6 +86,8 @@ export const ModelCapabilities = Schema.Struct({ supportsThinkingToggle: Schema.Boolean, contextWindowOptions: Schema.Array(ContextWindowOption), promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), + variantOptions: Schema.optional(Schema.Array(EffortOption)), + agentOptions: Schema.optional(Schema.Array(EffortOption)), }); export type ModelCapabilities = typeof ModelCapabilities.Type; @@ -68,19 +95,26 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", copilot: "gpt-5", claudeAgent: "claude-sonnet-4-6", + cursor: "auto", + opencode: "openai/gpt-5", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; +/** Per-provider text generation model defaults. */ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", copilot: "gpt-5-mini", claudeAgent: "claude-haiku-4-5", + cursor: "composer-2", + opencode: "openai/gpt-5", }; export const GIT_TEXT_GENERATION_PROVIDERS = [ "codex", "claudeAgent", + "opencode", + "cursor", ] as const satisfies ReadonlyArray; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -131,10 +165,26 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", copilot: "GitHub Copilot", claudeAgent: "Claude", + cursor: "Cursor", + opencode: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c60d281f..f814bb14 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,11 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions, CopilotModelOptions } from "./model.ts"; +import { + ClaudeModelOptions, + CodexModelOptions, + CopilotModelOptions, + CursorModelOptions, + OpenCodeModelOptions, +} from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, @@ -25,7 +31,13 @@ export const ORCHESTRATION_WS_METHODS = { subscribeThread: "orchestration.subscribeThread", } as const; -export const ProviderKind = Schema.Literals(["codex", "copilot", "claudeAgent"]); +export const ProviderKind = Schema.Literals([ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", +]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -64,10 +76,25 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; +export const CursorModelSelection = Schema.Struct({ + provider: Schema.Literal("cursor"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(CursorModelOptions), +}); +export type CursorModelSelection = typeof CursorModelSelection.Type; +export const OpenCodeModelSelection = Schema.Struct({ + provider: Schema.Literal("opencode"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(OpenCodeModelOptions), +}); +export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; + export const ModelSelection = Schema.Union([ CodexModelSelection, CopilotModelSelection, ClaudeModelSelection, + CursorModelSelection, + OpenCodeModelSelection, ]); export type ModelSelection = typeof ModelSelection.Type; @@ -432,6 +459,7 @@ const ProjectDeleteCommand = Schema.Struct({ type: Schema.Literal("project.delete"), commandId: CommandId, projectId: ProjectId, + force: Schema.optional(Schema.Boolean), }); const ThreadCreateCommand = Schema.Struct({ diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index bd20b7e9..bfab0ca9 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -68,6 +68,54 @@ 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); + } + }); + + it("derives provider from modelSelection when provider is omitted", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + runtimeMode: "full-access", + modelSelection: { + provider: "copilot", + model: "gpt-5", + }, + }); + + expect(parsed.provider).toBe("copilot"); + expect(parsed.modelSelection?.provider).toBe("copilot"); + }); + + it("rejects mismatched provider and modelSelection providers", () => { + expect(() => + decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "codex", + runtimeMode: "full-access", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }), + ).toThrow(/must match modelSelection provider/i); + }); }); describe("ProviderSendTurnInput", () => { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e27e3aa7..5ed380e5 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, @@ -46,7 +46,7 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; -export const ProviderSessionStartInput = Schema.Struct({ +const ProviderSessionStartInputBase = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyString), @@ -56,6 +56,31 @@ export const ProviderSessionStartInput = Schema.Struct({ sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode, }); +export const ProviderSessionStartInput = ProviderSessionStartInputBase.pipe( + Schema.decodeTo( + Schema.toType(ProviderSessionStartInputBase), + SchemaTransformation.transformOrFail({ + decode: (input) => { + const derivedProvider = input.provider ?? input.modelSelection?.provider; + if ( + input.provider !== undefined && + input.modelSelection !== undefined && + input.provider !== input.modelSelection.provider + ) { + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(input), { + message: `Provider session start input provider '${input.provider}' must match modelSelection provider '${input.modelSelection.provider}'.`, + }), + ); + } + return Effect.succeed( + derivedProvider === undefined ? input : { ...input, provider: derivedProvider }, + ); + }, + encode: Effect.succeed, + }), + ), +); export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 345e04f7..d20571f5 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -17,15 +17,18 @@ import { ProviderKind } from "./orchestration.ts"; 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", - "copilot.sdk.session-event", - "copilot.sdk.synthetic", +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("copilot.sdk.session-event"), + Schema.Literal("copilot.sdk.synthetic"), + Schema.Literal("opencode.sdk.event"), + Schema.Literal("acp.jsonrpc"), + Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index e26b3e33..2603d51d 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -23,22 +23,4 @@ describe("ServerProvider", () => { expect(parsed.slashCommands).toEqual([]); expect(parsed.skills).toEqual([]); }); - - it("keeps quotaSnapshots undefined when legacy snapshots omit them", () => { - const parsed = decodeServerProvider({ - provider: "codex", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { - status: "authenticated", - }, - checkedAt: "2026-04-10T00:00:00.000Z", - models: [], - }); - - expect(parsed.quotaSnapshots).toBeUndefined(); - expect("quotaSnapshots" in parsed).toBe(false); - }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9a780e29..85016b50 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -6,7 +6,9 @@ import { ClaudeModelOptions, CodexModelOptions, CopilotModelOptions, + CursorModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + OpenCodeModelOptions, } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; @@ -98,6 +100,22 @@ export const CopilotSettings = Schema.Struct({ }); export type CopilotSettings = typeof CopilotSettings.Type; +export const CursorSettings = Schema.Struct({ + 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([]))), +}); +export type CursorSettings = typeof CursorSettings.Type; +export const OpenCodeSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + binaryPath: makeBinaryPathSetting("opencode"), + serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type OpenCodeSettings = typeof OpenCodeSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -124,6 +142,8 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), copilot: CopilotSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); @@ -166,6 +186,22 @@ const ClaudeModelOptionsPatch = Schema.Struct({ contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), }); +const CopilotModelOptionsPatch = Schema.Struct({ + reasoningEffort: Schema.optionalKey(CopilotModelOptions.fields.reasoningEffort), +}); + +const CursorModelOptionsPatch = Schema.Struct({ + reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), + fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), + thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), + contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), +}); + +const OpenCodeModelOptionsPatch = Schema.Struct({ + variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), + agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -175,17 +211,23 @@ const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("copilot")), model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey( - Schema.Struct({ - reasoningEffort: Schema.optionalKey(CopilotModelOptions.fields.reasoningEffort), - }), - ), + options: Schema.optionalKey(CopilotModelOptionsPatch), }), Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("claudeAgent")), model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("cursor")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CursorModelOptionsPatch), + }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("opencode")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(OpenCodeModelOptionsPatch), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -209,6 +251,21 @@ const CopilotSettingsPatch = 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)), +}); + +const OpenCodeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + serverUrl: Schema.optionalKey(Schema.String), + serverPassword: 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), @@ -225,6 +282,8 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), copilot: Schema.optionalKey(CopilotSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + cursor: Schema.optionalKey(CursorSettingsPatch), + opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), }); diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json new file mode 100644 index 00000000..296d64fc --- /dev/null +++ b/packages/effect-acp/package.json @@ -0,0 +1,55 @@ +{ + "name": "effect-acp", + "private": true, + "type": "module", + "exports": { + "./client": { + "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" + }, + "./rpc": { + "types": "./src/rpc.ts", + "import": "./src/rpc.ts" + }, + "./protocol": { + "types": "./src/protocol.ts", + "import": "./src/protocol.ts" + }, + "./terminal": { + "types": "./src/terminal.ts", + "import": "./src/terminal.ts" + }, + "./errors": { + "types": "./src/errors.ts", + "import": "./src/errors.ts" + } + }, + "scripts": { + "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", + "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 00000000..f2837c4f --- /dev/null +++ b/packages/effect-acp/scripts/generate.ts @@ -0,0 +1,289 @@ +#!/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 GenerateCommandError { + readonly _tag: "GenerateCommandError"; + readonly message: string; +} + +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 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"), + "", + ].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;`, + "", + `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((code) => + code === 0 + ? Effect.void + : Effect.fail({ + _tag: "GenerateCommandError", + message: `oxfmt failed with exit code ${code}`, + }), + ), + ); +}); + +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 00000000..5d2dd3dd --- /dev/null +++ b/packages/effect-acp/src/_generated/meta.gen.ts @@ -0,0 +1,35 @@ +// 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", + 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 00000000..73fdc752 --- /dev/null +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -0,0 +1,10375 @@ +// 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 = { + 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 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; + 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 _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; + 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.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.", + }), + Schema.Null, + ]), + ), + id: Schema.String.annotate({ + description: "Unique identifier for a session configuration 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).", + }), + ), + 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.", + }), + 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; + 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 _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; + } + | 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.", + }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to 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.", + }), + 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\nRequests structured user input via a form or 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 _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; + 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." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to 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: "Request parameters for setting a session configuration option.", + }), + ]).annotate({ + title: "SetSessionConfigOptionRequest", + description: "Sets the current value for 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 _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; + }; +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.", + }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to 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.", + }), + 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; + 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 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 _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." }), + _meta: Schema.optionalKey( + Schema.Union([ + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: + "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to 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: "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/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts new file mode 100644 index 00000000..523889d0 --- /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.ts"; +import * as AcpError from "../errors.ts"; + +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* Effect.fail(AcpError.AcpRequestError.methodNotFound(method).toProtocolError()); + } + 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 00000000..b1757568 --- /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.ts"; + +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 00000000..22130bb5 --- /dev/null +++ b/packages/effect-acp/src/agent.test.ts @@ -0,0 +1,255 @@ +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.ts"; +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { + encodeJsonl, + jsonRpcNotification, + jsonRpcRequest, + jsonRpcResponse, +} from "./_internal/shared.ts"; +import { makeInMemoryStdio } from "./_internal/stdio.ts"; + +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 })); +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* () { + 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.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: permissionRequest.id, + 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))); + }), +); + +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 new file mode 100644 index 00000000..00a94147 --- /dev/null +++ b/packages/effect-acp/src/agent.ts @@ -0,0 +1,534 @@ +import * as Context from "effect/Context"; +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 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.ts"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; +import * as AcpError from "./errors.ts"; +import * as AcpProtocol from "./protocol.ts"; +import * as AcpRpcs from "./rpc.ts"; +import { + callRpc, + decodeExtNotificationRegistration, + decodeExtRequestRegistration, + runHandler, +} from "./_internal/shared.ts"; +import * as AcpTerminal from "./terminal.ts"; + +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 handleSetSessionMode: ( + handler: ( + request: AcpSchema.SetSessionModeRequest, + ) => 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 Context.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; + setSessionMode?: ( + request: AcpSchema.SetSessionModeRequest, + ) => 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_mode]: (payload) => + runHandler(coreHandlers.setSessionMode, payload, AGENT_METHODS.session_set_mode), + [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 << 32n; + 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; + }), + handleSetSessionMode: (handler) => + Effect.suspend(() => { + coreHandlers.setSessionMode = 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 new file mode 100644 index 00000000..b867231a --- /dev/null +++ b/packages/effect-acp/src/client.test.ts @@ -0,0 +1,450 @@ +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"; +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.ts"; +import * as AcpSchema from "./_generated/schema.gen.ts"; +import * as AcpError from "./errors.ts"; +import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared.ts"; +import { makeInMemoryStdio } from "./_internal/stdio.ts"; + +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"), +); + +it.layer(NodeServices.layer)("effect-acp client", (it) => { + 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); + }); + + 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 scope = yield* Scope.make(); + const acpLayer = AcpClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); + + const ext = 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.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.agent.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.agent.authenticate({ methodId: "cursor_login" }); + + const session = yield* acp.agent.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + assert.equal(session.sessionId, "mock-session-1"); + + 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.raw.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.raw.request("x/echo", { + hello: "world", + }); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + + assert.deepEqual(ext, { + echoedMethod: "x/echo", + echoedParams: { + hello: "world", + }, + }); + }), + ); + + 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 scope = yield* Scope.make(); + const acpLayer = AcpClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(acpLayer, scope); + + const result = 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.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: [], + }); + + return yield* Effect.exit( + acp.agent.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"); + } + const rendered = Cause.pretty(result.cause); + assert.include(rendered, "Invalid x/typed_request payload:"); + 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 new file mode 100644 index 00000000..ae3b566a --- /dev/null +++ b/packages/effect-acp/src/client.ts @@ -0,0 +1,577 @@ +import * as Context from "effect/Context"; +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 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 { ChildProcessSpawner } from "effect/unstable/process"; + +import * as AcpError from "./errors.ts"; +import * as AcpProtocol from "./protocol.ts"; +import * as AcpRpcs from "./rpc.ts"; +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; +import { + callRpc, + decodeExtNotificationRegistration, + decodeExtRequestRegistration, + runHandler, +} from "./_internal/shared.ts"; +import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; + +export interface AcpClientOptions { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect; +} + +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; + /** + * Selects the active session mode for a session. + * @see https://agentclientprotocol.com/protocol/schema#session/set_mode + */ + readonly setSessionMode: ( + payload: AcpSchema.SetSessionModeRequest, + ) => 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 + */ + readonly handleRequestPermission: ( + handler: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `session/elicitation`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation + */ + readonly handleElicitation: ( + handler: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `fs/read_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file + */ + readonly handleReadTextFile: ( + handler: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `fs/write_text_file`. + * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file + */ + readonly handleWriteTextFile: ( + handler: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `terminal/create`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/create + */ + readonly handleCreateTerminal: ( + handler: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `terminal/output`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/output + */ + readonly handleTerminalOutput: ( + handler: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `terminal/wait_for_exit`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit + */ + readonly handleTerminalWaitForExit: ( + handler: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `terminal/kill`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/kill + */ + readonly handleTerminalKill: ( + handler: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `terminal/release`. + * @see https://agentclientprotocol.com/protocol/schema#terminal/release + */ + readonly handleTerminalRelease: ( + handler: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `session/update`. + * @see https://agentclientprotocol.com/protocol/schema#session/update + */ + readonly handleSessionUpdate: ( + handler: ( + notification: AcpSchema.SessionNotification, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a handler for `session/elicitation/complete`. + * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete + */ + readonly handleElicitationComplete: ( + handler: ( + notification: AcpSchema.ElicitationCompleteNotification, + ) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a fallback extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtRequest: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a fallback extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleUnknownExtNotification: ( + handler: (method: string, params: unknown) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension request handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtRequest: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; + /** + * Registers a typed extension notification handler. + * @see https://agentclientprotocol.com/protocol/extensibility + */ + readonly handleExtNotification: ( + method: string, + payload: Schema.Codec, + handler: (payload: A) => Effect.Effect, + ) => Effect.Effect; +} + +export class AcpClient extends Context.Service()( + "effect-acp/AcpClient", +) {} + +interface AcpCoreRequestHandlers { + requestPermission?: ( + request: AcpSchema.RequestPermissionRequest, + ) => Effect.Effect; + elicitation?: ( + request: AcpSchema.ElicitationRequest, + ) => Effect.Effect; + readTextFile?: ( + request: AcpSchema.ReadTextFileRequest, + ) => Effect.Effect; + writeTextFile?: ( + request: AcpSchema.WriteTextFileRequest, + ) => Effect.Effect; + createTerminal?: ( + request: AcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + terminalOutput?: ( + request: AcpSchema.TerminalOutputRequest, + ) => Effect.Effect; + terminalWaitForExit?: ( + request: AcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect; + terminalKill?: ( + request: AcpSchema.KillTerminalRequest, + ) => Effect.Effect; + terminalRelease?: ( + request: AcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect; +} + +interface AcpNotificationHandlers { + 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* ( + stdio: Stdio.Stdio, + options: AcpClientOptions = {}, + terminationError?: Effect.Effect, +): Effect.fn.Return { + const coreHandlers: AcpCoreRequestHandlers = {}; + const notificationHandlers: AcpNotificationHandlers = { + sessionUpdate: { handlers: [], pending: [] }, + elicitationComplete: { handlers: [], pending: [] }, + }; + 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 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": { + 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, + notification.params, + ); + } + 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: stdio, + ...(terminationError ? { terminationError } : {}), + 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: dispatchNotification, + onExtRequest: dispatchExtRequest, + }); + + const clientHandlerLayer = AcpRpcs.ClientRpcs.toLayer( + AcpRpcs.ClientRpcs.of({ + [CLIENT_METHODS.session_request_permission]: (payload) => + runHandler( + coreHandlers.requestPermission, + payload, + CLIENT_METHODS.session_request_permission, + ), + [CLIENT_METHODS.session_elicitation]: (payload) => + runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation), + [CLIENT_METHODS.fs_read_text_file]: (payload) => + runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), + [CLIENT_METHODS.fs_write_text_file]: (payload) => + runHandler(coreHandlers.writeTextFile, payload, CLIENT_METHODS.fs_write_text_file).pipe( + Effect.map((result) => result ?? {}), + ), + [CLIENT_METHODS.terminal_create]: (payload) => + runHandler(coreHandlers.createTerminal, payload, CLIENT_METHODS.terminal_create), + [CLIENT_METHODS.terminal_output]: (payload) => + runHandler(coreHandlers.terminalOutput, payload, CLIENT_METHODS.terminal_output), + [CLIENT_METHODS.terminal_wait_for_exit]: (payload) => + runHandler( + coreHandlers.terminalWaitForExit, + payload, + CLIENT_METHODS.terminal_wait_for_exit, + ), + [CLIENT_METHODS.terminal_kill]: (payload) => + runHandler(coreHandlers.terminalKill, payload, CLIENT_METHODS.terminal_kill).pipe( + Effect.map((result) => result ?? {}), + ), + [CLIENT_METHODS.terminal_release]: (payload) => + runHandler(coreHandlers.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, + ); + + let nextRpcRequestId = 1n << 32n; + const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs, { + generateRequestId: () => nextRpcRequestId++ as never, + }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol)); + + 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)), + setSessionMode: (payload) => callRpc(rpc[AGENT_METHODS.session_set_mode](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; + 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.handlers.push(handler); + return flushBufferedNotifications(notificationHandlers.sessionUpdate); + }), + handleElicitationComplete: (handler) => + Effect.suspend(() => { + notificationHandlers.elicitationComplete.handlers.push(handler); + return flushBufferedNotifications(notificationHandlers.elicitationComplete); + }), + 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 layerChildProcess = ( + handle: ChildProcessSpawner.ChildProcessHandle, + 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/errors.ts b/packages/effect-acp/src/errors.ts new file mode 100644 index 00000000..b2fa3912 --- /dev/null +++ b/packages/effect-acp/src/errors.ts @@ -0,0 +1,139 @@ +import * as Schema from "effect/Schema"; + +import * as AcpSchema from "./_generated/schema.gen.ts"; + +export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { + command: Schema.optional(Schema.String), + cause: 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" + : `ACP process exited 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.Defect, + }, +) {} + +export class AcpRequestError extends Schema.TaggedErrorClass()("AcpRequestError", { + code: AcpSchema.ErrorCode, + errorMessage: Schema.String, + data: Schema.optional(Schema.Unknown), +}) { + override get message() { + return this.errorMessage; + } + + static fromProtocolError(error: AcpSchema.Error) { + 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() { + return AcpSchema.Error.make({ + code: this.code, + message: this.errorMessage, + ...(this.data !== undefined ? { data: this.data } : {}), + }); + } +} + +export const AcpError = Schema.Union([ + AcpRequestError, + AcpSpawnError, + AcpProcessExitedError, + AcpProtocolParseError, + AcpTransportError, +]); + +export type AcpError = typeof AcpError.Type; diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts new file mode 100644 index 00000000..8aaa0810 --- /dev/null +++ b/packages/effect-acp/src/protocol.test.ts @@ -0,0 +1,448 @@ +import * as Path from "effect/Path"; +import * as AcpError from "./errors.ts"; +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 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.ts"; +import * as AcpProtocol from "./protocol.ts"; +import { + encodeJsonl, + jsonRpcNotification, + jsonRpcRequest, + jsonRpcResponse, +} from "./_internal/shared.ts"; +import { makeInMemoryStdio, makeTerminationError, makeChildStdio } from "./_internal/stdio.ts"; + +const SessionCancelNotification = jsonRpcNotification( + "session/cancel", + AcpSchema.CancelNotification, +); +const SessionUpdateNotification = jsonRpcNotification( + "session/update", + AcpSchema.SessionNotification, +); +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 })); + +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); + }); + +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.incoming.pipe( + Stream.take(2), + Stream.runCollect, + Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), + Effect.forkScoped, + ); + + yield* transport.notify("session/cancel", { sessionId: "session-1" }); + const outbound = yield* Queue.take(output); + assert.deepEqual( + yield* Schema.decodeEffect(Schema.fromJsonString(SessionCancelNotification))(outbound), + { + jsonrpc: "2.0", + method: "session/cancel", + params: { + sessionId: "session-1", + }, + }, + ); + + yield* Queue.offer( + input, + 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", + }, + ], + }, + }, + }), + ); + + yield* Queue.offer( + input, + yield* encodeJsonl(ElicitationCompleteNotification, { + jsonrpc: "2.0", + method: "session/elicitation/complete", + params: { + elicitationId: "elicitation-1", + }, + }), + ); + + const [update, completion] = yield* Deferred.await(notifications); + assert.equal(update?._tag, "SessionUpdate"); + assert.equal(completion?._tag, "ElicitationComplete"); + }), + ); + + 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.notify("session/cancel", { sessionId: "session-1" }); + + assert.deepEqual(events, [ + { + direction: "outgoing", + stage: "decoded", + payload: { + _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"},"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.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.notify("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(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .request("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + const outbound = yield* Queue.take(output); + assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), { + jsonrpc: "2.0", + id: 1, + method: "x/test", + params: { + hello: "world", + }, + headers: [], + }); + + yield* Queue.offer( + input, + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + }), + ); + + const resolved = yield* Fiber.join(response); + assert.deepEqual(resolved, { ok: true }); + }), + ); + + 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(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + const lateResponse = yield* Deferred.make(); + + yield* transport.clientProtocol + .run(0, (message) => Deferred.succeed(lateResponse, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const response = yield* transport + .request("x/test", { hello: "world" }) + .pipe(Effect.forkScoped); + const outbound = yield* Queue.take(output); + assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), { + jsonrpc: "2.0", + id: 1, + method: "x/test", + params: { + hello: "world", + }, + headers: [], + }); + + yield* Fiber.interrupt(response); + yield* Queue.offer( + input, + yield* encodeJsonl(ExtResponse, { + jsonrpc: "2.0", + id: 1, + result: { + ok: true, + }, + }), + ); + + 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 termination = yield* Deferred.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeChildStdio(handle), + terminationError: makeTerminationError(handle), + serverRequestMethods: new Set(), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* transport.clientProtocol + .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const message = yield* Deferred.await(firstMessage); + const exitError = yield* Deferred.await(termination); + assert.instanceOf(exitError, AcpError.AcpProcessExitedError); + 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; + 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 terminationCalls = yield* Ref.make(0); + const firstMessage = yield* Deferred.make(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio: makeChildStdio(handle), + terminationError: makeTerminationError(handle), + serverRequestMethods: new Set(), + onTermination: () => Ref.update(terminationCalls, (count) => count + 1), + }); + + yield* transport.clientProtocol + .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + const message = yield* Deferred.await(firstMessage); + 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; + 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, + terminationError: Effect.succeed(new AcpError.AcpProcessExitedError({ code: 0 })), + serverRequestMethods: new Set(), + }); + + const response = yield* transport + .request("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 new file mode 100644 index 00000000..204cf979 --- /dev/null +++ b/packages/effect-acp/src/protocol.ts @@ -0,0 +1,536 @@ +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.ts"; +import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; +import * as AcpError from "./errors.ts"; + +export interface AcpProtocolLogEvent { + readonly direction: "incoming" | "outgoing"; + readonly stage: "raw" | "decoded" | "decode_failed"; + readonly payload: unknown; +} + +export type AcpIncomingNotification = + | { + readonly _tag: "SessionUpdate"; + readonly method: typeof CLIENT_METHODS.session_update; + readonly params: typeof AcpSchema.SessionNotification.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 terminationError?: Effect.Effect; + readonly serverRequestMethods: ReadonlySet; + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect; + readonly onNotification?: ( + notification: AcpIncomingNotification, + ) => Effect.Effect; + readonly onExtRequest?: ( + method: string, + params: unknown, + ) => Effect.Effect; + readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect; +} + +export interface AcpPatchedProtocol { + readonly clientProtocol: RpcClient.Protocol["Service"]; + readonly serverProtocol: RpcServer.Protocol["Service"]; + 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); +const decodeElicitationComplete = Schema.decodeUnknownEffect( + AcpSchema.ElicitationCompleteNotification, +); +const parserFactory = RpcSerialization.ndJsonRpc(); + +export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( + options: AcpPatchedProtocolOptions, +): 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 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, + }), + }); + + 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, + 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 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.getAndSet(extPending, new Map()).pipe( + Effect.flatMap((pending) => + Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), { + discard: true, + }), + ), + ); + + 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, + }), + }), + }).pipe(Effect.asVoid); + + const handleTermination = (classify: () => Effect.Effect) => + Ref.modify(terminationHandled, (handled) => { + if (handled) { + return [Effect.void, true] as const; + } + return [ + Effect.gen(function* () { + yield* Queue.offer(disconnects, 0); + const error = yield* classify(); + if (!error) { + return; + } + yield* failAllExtPending(error); + yield* emitClientProtocolError(error); + if (options.onTermination) { + yield* options.onTermination(error); + } + }), + 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)); + } + 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 === 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) => + 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(normalized)); + }, + onSuccess: () => + handleTermination( + () => + options.terminationError ?? + Effect.succeed( + new AcpError.AcpTransportError({ + detail: "ACP input stream ended", + cause: new Error("ACP input stream ended"), + }), + ), + ), + }), + Effect.forkScoped, + ); + + yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); + + const clientProtocol = RpcClient.Protocol.of({ + run: (_clientId, f) => + Stream.fromQueue(clientQueue).pipe( + Stream.runForEach((message) => f(message)), + Effect.forever, + ), + send: (_clientId, 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: (_clientId) => Queue.end(outgoing), + 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: [], + }); + }); + + 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 } { + return ( + typeof value === "object" && + value !== null && + "code" in value && + typeof value.code === "number" && + "message" in value && + typeof value.message === "string" + ); +} + +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 { + 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 00000000..e51ab83f --- /dev/null +++ b/packages/effect-acp/src/rpc.ts @@ -0,0 +1,165 @@ +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; + +import * as AcpSchema from "./_generated/schema.gen.ts"; +import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; + +export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { + payload: AcpSchema.InitializeRequest, + success: AcpSchema.InitializeResponse, + error: AcpSchema.Error, +}); + +export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { + payload: AcpSchema.AuthenticateRequest, + success: AcpSchema.AuthenticateResponse, + error: AcpSchema.Error, +}); + +export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { + payload: AcpSchema.LogoutRequest, + success: AcpSchema.LogoutResponse, + error: AcpSchema.Error, +}); + +export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { + payload: AcpSchema.NewSessionRequest, + success: AcpSchema.NewSessionResponse, + error: AcpSchema.Error, +}); + +export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { + payload: AcpSchema.LoadSessionRequest, + success: AcpSchema.LoadSessionResponse, + error: AcpSchema.Error, +}); + +export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { + payload: AcpSchema.ListSessionsRequest, + success: AcpSchema.ListSessionsResponse, + error: AcpSchema.Error, +}); + +export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { + payload: AcpSchema.ForkSessionRequest, + success: AcpSchema.ForkSessionResponse, + error: AcpSchema.Error, +}); + +export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { + payload: AcpSchema.ResumeSessionRequest, + success: AcpSchema.ResumeSessionResponse, + error: AcpSchema.Error, +}); + +export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { + payload: AcpSchema.CloseSessionRequest, + success: AcpSchema.CloseSessionResponse, + error: AcpSchema.Error, +}); + +export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { + payload: AcpSchema.PromptRequest, + success: AcpSchema.PromptResponse, + error: AcpSchema.Error, +}); + +export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { + payload: AcpSchema.SetSessionModelRequest, + success: AcpSchema.SetSessionModelResponse, + error: AcpSchema.Error, +}); + +export const SetSessionModeRpc = Rpc.make(AGENT_METHODS.session_set_mode, { + payload: AcpSchema.SetSessionModeRequest, + success: AcpSchema.SetSessionModeResponse, + error: AcpSchema.Error, +}); + +export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { + payload: AcpSchema.SetSessionConfigOptionRequest, + success: AcpSchema.SetSessionConfigOptionResponse, + error: AcpSchema.Error, +}); + +export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { + payload: AcpSchema.ReadTextFileRequest, + success: AcpSchema.ReadTextFileResponse, + error: AcpSchema.Error, +}); + +export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { + payload: AcpSchema.WriteTextFileRequest, + success: AcpSchema.WriteTextFileResponse, + error: AcpSchema.Error, +}); + +export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { + payload: AcpSchema.RequestPermissionRequest, + success: AcpSchema.RequestPermissionResponse, + error: AcpSchema.Error, +}); + +export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { + payload: AcpSchema.ElicitationRequest, + success: AcpSchema.ElicitationResponse, + error: AcpSchema.Error, +}); + +export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { + payload: AcpSchema.CreateTerminalRequest, + success: AcpSchema.CreateTerminalResponse, + error: AcpSchema.Error, +}); + +export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { + payload: AcpSchema.TerminalOutputRequest, + success: AcpSchema.TerminalOutputResponse, + error: AcpSchema.Error, +}); + +export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { + payload: AcpSchema.ReleaseTerminalRequest, + success: AcpSchema.ReleaseTerminalResponse, + error: AcpSchema.Error, +}); + +export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { + payload: AcpSchema.WaitForTerminalExitRequest, + success: AcpSchema.WaitForTerminalExitResponse, + error: AcpSchema.Error, +}); + +export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { + payload: AcpSchema.KillTerminalRequest, + success: AcpSchema.KillTerminalResponse, + error: AcpSchema.Error, +}); + +export const AgentRpcs = RpcGroup.make( + InitializeRpc, + AuthenticateRpc, + LogoutRpc, + NewSessionRpc, + LoadSessionRpc, + ListSessionsRpc, + ForkSessionRpc, + ResumeSessionRpc, + CloseSessionRpc, + PromptRpc, + SetSessionModelRpc, + SetSessionModeRpc, + SetSessionConfigOptionRpc, +); + +export const ClientRpcs = RpcGroup.make( + ReadTextFileRpc, + WriteTextFileRpc, + RequestPermissionRpc, + ElicitationRpc, + CreateTerminalRpc, + TerminalOutputRpc, + ReleaseTerminalRpc, + WaitForTerminalExitRpc, + KillTerminalRpc, +); diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts new file mode 100644 index 00000000..8e354aca --- /dev/null +++ b/packages/effect-acp/src/schema.ts @@ -0,0 +1,2 @@ +export * from "./_generated/schema.gen.ts"; +export * from "./_generated/meta.gen.ts"; diff --git a/packages/effect-acp/src/terminal.ts b/packages/effect-acp/src/terminal.ts new file mode 100644 index 00000000..088ff863 --- /dev/null +++ b/packages/effect-acp/src/terminal.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; + +import type * as AcpSchema from "./_generated/schema.gen.ts"; +import type * as AcpError from "./errors.ts"; + +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, + }; +} 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 00000000..929ed626 --- /dev/null +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -0,0 +1,81 @@ +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"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as AcpClient from "../../src/client.ts"; + +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.layerChildProcess(handle, { + logIncoming: true, + logOutgoing: true, + }); + + yield* Effect.gen(function* () { + const acp = yield* AcpClient.AcpClient; + + yield* acp.handleRequestPermission(() => + Effect.succeed({ + outcome: { + outcome: "selected", + optionId: "allow", + }, + }), + ); + // 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, null, 4)); + + const session = yield* acp.agent.createSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + const config = yield* acp.agent.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "claude-opus-4-6", + }); + + yield* Console.log("config", JSON.stringify(config, null, 4)); + + const result = yield* acp.agent.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* Console.log("prompt result", JSON.stringify(result)); + yield* acp.agent.cancel({ sessionId: session.sessionId }); + }).pipe(Effect.provide(acpLayer)); +}); + +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 new file mode 100644 index 00000000..7ff88a2c --- /dev/null +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -0,0 +1,136 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; + +import * as AcpAgent from "../../src/agent.ts"; + +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)); +} + +const sessionId = "mock-session-1"; + +const program = Effect.gen(function* () { + const agent = yield* AcpAgent.AcpAgent; + + yield* agent.handleInitialize(() => + Effect.succeed({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { + list: {}, + }, + }, + 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(), + }, + ], + }), + ); + + yield* agent.handlePrompt(() => + Effect.gen(function* () { + yield* agent.client.requestPermission({ + sessionId, + options: [ + { + optionId: "allow", + name: "Allow", + kind: "allow_once", + }, + ], + toolCall: { + toolCallId: "tool-1", + title: "Read project files", + }, + }); + + yield* agent.client.elicit({ + sessionId, + message: "Need confirmation before continuing.", + mode: "form", + requestedSchema: { + type: "object", + title: "Need confirmation", + properties: { + approved: { + type: "boolean", + title: "Approved", + }, + }, + required: ["approved"], + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Inspect the repository", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + + yield* agent.client.elicitationComplete({ + elicitationId: "elicitation-1", + }); + + yield* agent.client.extRequest("x/typed_request", { + message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request", + }); + + yield* agent.client.extNotification("x/typed_notification", { + count: 2, + }); + + return { + stopReason: "end_turn" as const, + }; + }), + ); + + yield* agent.handleUnknownExtRequest((method, params) => + Effect.succeed({ + echoedMethod: method, + echoedParams: params ?? null, + }), + ); + + return yield* Effect.never; +}); + +program.pipe( + Effect.provide(Layer.provide(AcpAgent.layerStdio(), NodeServices.layer)), + NodeRuntime.runMain, +); diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json new file mode 100644 index 00000000..61162f94 --- /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"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 3789e3cf..82085dfc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,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/model.test.ts b/packages/shared/src/model.test.ts index 314d0c6b..426ceca8 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,12 +10,9 @@ import { isClaudeUltrathinkPrompt, normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, - normalizeCopilotModelOptionsWithCapabilities, normalizeModelSlug, - resolveApiModelId, resolveContextWindow, resolveEffort, - resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, @@ -47,19 +44,10 @@ const claudeCaps: ModelCapabilities = { promptInjectedEffortLevels: ["ultrathink"], }; -const noOptionsCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; - describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeModelSlug("opus", "copilot")).toBe("claude-opus-4.7"); expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); }); @@ -71,13 +59,19 @@ 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(resolveModelSlugForProvider("codex", "custom/internal-model")).toBe( + "custom/internal-model", + ); + }); }); describe("resolveSelectableModel", () => { @@ -87,100 +81,170 @@ describe("resolveSelectableModel", () => { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]; expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); + expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); }); describe("capability helpers", () => { - it("read defaults and support", () => { + it("reads default efforts", () => { expect(getDefaultEffort(codexCaps)).toBe("high"); expect(getDefaultEffort(claudeCaps)).toBe("high"); + }); + + it("checks effort support", () => { expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); - expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); - expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); + expect(hasEffortLevel(codexCaps, "max")).toBe(false); }); }); describe("resolveEffort", () => { - it("resolves supported values and defaults", () => { + it("returns the explicit value when supported and not prompt-injected", () => { expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); + expect(resolveEffort(codexCaps, "high")).toBe("high"); + expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); + }); + + it("falls back to default when value is unsupported", () => { expect(resolveEffort(codexCaps, "bogus")).toBe("high"); + expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); + }); + + it("returns the default when no value is provided", () => { + expect(resolveEffort(codexCaps, undefined)).toBe("high"); + expect(resolveEffort(codexCaps, null)).toBe("high"); + expect(resolveEffort(codexCaps, "")).toBe("high"); + expect(resolveEffort(codexCaps, " ")).toBe("high"); + }); + + it("excludes prompt-injected efforts and falls back to default", () => { expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); }); -}); -describe("resolveContextWindow", () => { - it("resolves explicit and default values", () => { - expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); - expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); - expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); + it("returns undefined for models with no effort levels", () => { + const noCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; + expect(resolveEffort(noCaps, undefined)).toBeUndefined(); + expect(resolveEffort(noCaps, "high")).toBeUndefined(); }); }); describe("misc helpers", () => { - it("handles prompt effort and trim", () => { + it("detects ultrathink prompts", () => { + expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); + expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); + }); + + it("prefixes ultrathink prompts once", () => { expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( "Ultrathink:\nInvestigate", ); + expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( + "Ultrathink:\nInvestigate", + ); + }); + + it("trims strings to null", () => { expect(trimOrNull(" hi ")).toBe("hi"); + expect(trimOrNull(" ")).toBeNull(); }); }); -describe("resolveApiModelId", () => { - it("applies claude context window suffix", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-opus-4-6[1m]"); +describe("context window helpers", () => { + it("reads default context window", () => { + expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); + }); + + it("returns null for models without context window options", () => { + expect(getDefaultContextWindow(codexCaps)).toBeNull(); }); - it("leaves codex untouched", () => { - expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); + it("checks context window support", () => { + expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); + expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); }); }); -describe("normalize model options", () => { - it("preserves codex fast mode and claude context window", () => { - expect( - normalizeCodexModelOptionsWithCapabilities(codexCaps, { - reasoningEffort: "high", - fastMode: false, - }), - ).toEqual({ reasoningEffort: "high", fastMode: false }); +describe("resolveContextWindow", () => { + it("returns the explicit value when supported", () => { + expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); + expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); + }); - expect( - normalizeClaudeModelOptionsWithCapabilities(claudeCaps, { - effort: "high", - contextWindow: "200k", - }), - ).toEqual({ effort: "high", contextWindow: "200k" }); + it("falls back to default when value is unsupported", () => { + expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); + }); + + it("returns the default when no value is provided", () => { + expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); }); - it("returns undefined when normalization removes every option", () => { + it("returns undefined for models with no context window options", () => { + expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); + expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); + }); +}); + +describe("normalize*ModelOptionsWithCapabilities", () => { + it("preserves explicit false codex fast mode", () => { expect( - normalizeCodexModelOptionsWithCapabilities(noOptionsCaps, { + normalizeCodexModelOptionsWithCapabilities(codexCaps, { reasoningEffort: "high", - fastMode: true, + fastMode: false, }), - ).toBeUndefined(); + ).toEqual({ + reasoningEffort: "high", + fastMode: false, + }); + }); + it("preserves the default Claude context window explicitly", () => { expect( - normalizeCopilotModelOptionsWithCapabilities(noOptionsCaps, { - reasoningEffort: "high", - }), - ).toBeUndefined(); + normalizeClaudeModelOptionsWithCapabilities( + { + ...claudeCaps, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }, + { + effort: "high", + contextWindow: "200k", + }, + ), + ).toEqual({ + effort: "high", + contextWindow: "200k", + }); + }); + it("omits unsupported Claude context window options", () => { expect( - normalizeClaudeModelOptionsWithCapabilities(noOptionsCaps, { - effort: "high", - thinking: false, - fastMode: true, - contextWindow: "1m", - }), - ).toBeUndefined(); + normalizeClaudeModelOptionsWithCapabilities( + { + ...claudeCaps, + reasoningEffortLevels: [], + supportsThinkingToggle: true, + contextWindowOptions: [], + }, + { + thinking: true, + contextWindow: "1m", + }, + ), + ).toEqual({ + thinking: true, + }); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 094b947f..33cd52d1 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -5,9 +5,12 @@ import { type ClaudeModelOptions, type CodexModelOptions, type CopilotModelOptions, + type CursorModelOptions, type ModelCapabilities, type ModelSelection, + type OpenCodeModelOptions, type ProviderKind, + type ProviderModelOptions, } from "@t3tools/contracts"; export interface SelectableModelOption { @@ -15,14 +18,23 @@ export interface SelectableModelOption { name: string; } +/** 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); } +/** Return the default effort value for a capabilities object, or null if none. */ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } +/** + * Resolve a raw effort option against capabilities. + * + * 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, raw: string | null | undefined, @@ -39,14 +51,22 @@ export function resolveEffort( return defaultValue ?? undefined; } +/** 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); } +/** Return the default context window value, or `null` if none is defined. */ export function getDefaultContextWindow(caps: ModelCapabilities): string | null { return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; } +/** + * Resolve a raw `contextWindow` option against capabilities. + * + * 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, raw: string | null | undefined, @@ -56,28 +76,19 @@ export function resolveContextWindow( return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); } -function emptyObjectToUndefined(value: T): T | undefined { - return Object.keys(value).length === 0 ? undefined : value; -} - -type Mutable = { - -readonly [K in keyof T]: T[K]; -}; - export function normalizeCodexModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; - const normalized: Mutable = {}; - if (reasoningEffort) { - normalized.reasoningEffort = reasoningEffort as CodexModelOptions["reasoningEffort"]; - } - if (fastMode !== undefined) { - normalized.fastMode = fastMode; - } - return emptyObjectToUndefined(normalized); + const nextOptions: CodexModelOptions = { + ...(reasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } export function normalizeCopilotModelOptionsWithCapabilities( @@ -85,11 +96,12 @@ export function normalizeCopilotModelOptionsWithCapabilities( modelOptions: CopilotModelOptions | null | undefined, ): CopilotModelOptions | undefined { const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const normalized: Mutable = {}; - if (reasoningEffort) { - normalized.reasoningEffort = reasoningEffort as CopilotModelOptions["reasoningEffort"]; - } - return emptyObjectToUndefined(normalized); + const nextOptions: CopilotModelOptions = reasoningEffort + ? { + reasoningEffort: reasoningEffort as CopilotModelOptions["reasoningEffort"], + } + : {}; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } export function normalizeClaudeModelOptionsWithCapabilities( @@ -100,20 +112,81 @@ export function normalizeClaudeModelOptionsWithCapabilities( const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const normalized: Mutable = {}; - if (thinking !== undefined) { - normalized.thinking = thinking; - } - if (effort) { - normalized.effort = effort as ClaudeModelOptions["effort"]; + const nextOptions: ClaudeModelOptions = { + ...(thinking !== undefined ? { thinking } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeCursorModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CursorModelOptions | null | undefined, +): CursorModelOptions | undefined { + const reasoning = resolveEffort(caps, modelOptions?.reasoning); + const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; + const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: CursorModelOptions = { + ...(reasoning ? { reasoning: reasoning as CursorModelOptions["reasoning"] } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(thinking !== undefined ? { thinking } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +function resolveLabeledOption( + options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, + raw: string | null | undefined, +): string | undefined { + if (!options || options.length === 0) { + return raw ?? undefined; } - if (fastMode !== undefined) { - normalized.fastMode = fastMode; + if (raw && options.some((option) => option.value === raw)) { + return raw; } - if (contextWindow !== undefined) { - normalized.contextWindow = contextWindow as ClaudeModelOptions["contextWindow"]; + return options.find((option) => option.isDefault)?.value; +} + +export function normalizeOpenCodeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: OpenCodeModelOptions | null | undefined, +): OpenCodeModelOptions | undefined { + const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); + const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); + const nextOptions: OpenCodeModelOptions = { + ...(variant ? { variant } : {}), + ...(agent ? { agent } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeProviderModelOptionsWithCapabilities( + provider: ProviderKind, + caps: ModelCapabilities, + modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, +): ProviderModelOptions[ProviderKind] | undefined { + switch (provider) { + case "codex": + return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); + case "copilot": + return normalizeCopilotModelOptionsWithCapabilities( + caps, + modelOptions as CopilotModelOptions, + ); + case "claudeAgent": + return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); + case "cursor": + return normalizeCursorModelOptionsWithCapabilities(caps, modelOptions as CursorModelOptions); + case "opencode": + return normalizeOpenCodeModelOptionsWithCapabilities( + caps, + modelOptions as OpenCodeModelOptions, + ); } - return emptyObjectToUndefined(normalized); } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -173,7 +246,7 @@ export function resolveSelectableModel( 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]; @@ -188,12 +261,6 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } -export function trimOrNull(value: T | null | undefined): T | null { - if (typeof value !== "string") return null; - const trimmed = value.trim() as T; - return trimmed || null; -} - export function resolveApiModelId(modelSelection: ModelSelection): string { switch (modelSelection.provider) { case "claudeAgent": { @@ -210,6 +277,52 @@ export function resolveApiModelId(modelSelection: ModelSelection): string { } } +/** Trim a string, returning null for empty/missing values. */ +export function trimOrNull(value: T | null | undefined): T | null { + if (typeof value !== "string") return null; + const trimmed = value.trim() as T; + return trimmed || null; +} + +export function createModelSelection( + provider: ProviderKind, + model: string, + options?: ProviderModelOptions[ProviderKind] | undefined, +): ModelSelection { + switch (provider) { + case "codex": + return { + provider, + model, + ...(options ? { options: options as CodexModelOptions } : {}), + }; + case "copilot": + return { + provider, + model, + ...(options ? { options: options as CopilotModelOptions } : {}), + }; + case "claudeAgent": + return { + provider, + model, + ...(options ? { options: options as ClaudeModelOptions } : {}), + }; + case "cursor": + return { + provider, + model, + ...(options ? { options: options as CursorModelOptions } : {}), + }; + case "opencode": + return { + provider, + model, + ...(options ? { options: options as OpenCodeModelOptions } : {}), + }; + } +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeAgentEffort | null | undefined, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 155e18cf..bbe5d8dc 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -110,7 +110,7 @@ describe("serverSettings helpers", () => { }); }); - it("uses the new provider default git model when switching providers without a model", () => { + it("replaces text generation selection across providers without leaking stale options", () => { const current = { ...DEFAULT_SERVER_SETTINGS, textGenerationModelSelection: { @@ -126,35 +126,13 @@ describe("serverSettings helpers", () => { expect( applyServerSettingsPatch(current, { textGenerationModelSelection: { - provider: "claudeAgent", + provider: "opencode", + model: "openai/gpt-5", }, }).textGenerationModelSelection, ).toEqual({ - provider: "claudeAgent", - model: "claude-haiku-4-5", + provider: "opencode", + model: "openai/gpt-5", }); }); - - it("preserves Claude launchArgs when applying a provider settings patch", () => { - const current = { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - claudeAgent: { - ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, - launchArgs: "--dangerously-skip-permissions", - }, - }, - }; - - expect( - applyServerSettingsPatch(current, { - providers: { - claudeAgent: { - launchArgs: "--verbose --dangerously-skip-permissions", - }, - }, - }).providers.claudeAgent.launchArgs, - ).toBe("--verbose --dangerously-skip-permissions"); - }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 411e9636..38e63641 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import { Schema } from "effect"; import { deepMerge } from "./Struct.ts"; +import { createModelSelection } from "./model.ts"; import { fromLenientJson } from "./schemaJson.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -65,66 +66,16 @@ export function applyServerSettingsPatch( return next; } - const currentProvider = current.textGenerationModelSelection.provider; - const provider = selectionPatch.provider ?? currentProvider; + const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; const model = selectionPatch.model ?? - (selectionPatch.provider !== undefined && selectionPatch.provider !== currentProvider + (selectionPatch.provider !== undefined && + selectionPatch.provider !== current.textGenerationModelSelection.provider ? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider] : current.textGenerationModelSelection.model); - if (provider === "codex") { - const textGenerationModelSelection = selectionPatch.options - ? ({ - provider: "codex", - model, - options: selectionPatch.options as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "codex" } - >["options"], - } as Extract) - : ({ provider: "codex", model } as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "codex" } - >); - return { - ...next, - textGenerationModelSelection, - }; - } - if (provider === "copilot") { - const textGenerationModelSelection = selectionPatch.options - ? ({ - provider: "copilot", - model, - options: selectionPatch.options as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "copilot" } - >["options"], - } as Extract) - : ({ provider: "copilot", model } as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "copilot" } - >); - return { - ...next, - textGenerationModelSelection, - }; - } - const textGenerationModelSelection = selectionPatch.options - ? ({ - provider: "claudeAgent", - model, - options: selectionPatch.options as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "claudeAgent" } - >["options"], - } as Extract) - : ({ provider: "claudeAgent", model } as Extract< - ServerSettings["textGenerationModelSelection"], - { provider: "claudeAgent" } - >); + return { ...next, - textGenerationModelSelection, + textGenerationModelSelection: createModelSelection(provider, model, selectionPatch.options), }; } diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts new file mode 100644 index 00000000..e31ea4e3 --- /dev/null +++ b/packages/shared/src/toolActivity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { deriveToolActivityPresentation } from "./toolActivity.ts"; + +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 00000000..5e2f1804 --- /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/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index e66ad091..86452693 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,7 +3,6 @@ import { assert, it } from "@effect/vitest"; import { ConfigProvider, Effect, Option } from "effect"; import { - createBuildConfig, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -43,23 +42,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); }); - it.effect("keeps electron-builder npm rebuilds enabled for Windows artifacts", () => - Effect.gen(function* () { - const config = yield* createBuildConfig("win", "nsis", "0.0.17", false, false, undefined); - assert.equal("npmRebuild" in config, false); - }), - ); - - it.effect("keeps Windows executable resource editing enabled for unsigned artifacts", () => - Effect.gen(function* () { - const config = yield* createBuildConfig("win", "nsis", "0.0.17", false, false, undefined); - assert.deepStrictEqual(config.win, { - target: ["nsis"], - icon: "icon.ico", - }); - }), - ); - it.effect("normalizes mock update server ports from env-style strings", () => Effect.gen(function* () { assert.equal(yield* resolveMockUpdateServerPort(undefined), undefined); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3810a0ea..74e8bed0 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -558,7 +558,7 @@ export function resolveDesktopProductName(version: string): string { : (desktopPackageJson.productName ?? "T3 Code"); } -export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( +const createBuildConfig = Effect.fn("createBuildConfig")(function* ( platform: typeof BuildPlatform.Type, target: string, version: string, @@ -610,12 +610,15 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "win") { + buildConfig.npmRebuild = false; const winConfig: Record = { target: [target], icon: "icon.ico", }; if (signed) { winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; + } else { + winConfig.signAndEditExecutable = false; } buildConfig.win = winConfig; } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 429dbef1..ce4865ec 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,6 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; -import { resolve } from "node:path"; import { assert, describe, it } from "@effect/vitest"; import { Effect, Path } from "effect"; @@ -220,63 +219,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); }), ); - - it.effect("pins desktop dev to a stable backend port and websocket url", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev:desktop", - baseEnv: { - T3CODE_PORT: "13773", - T3CODE_MODE: "web", - T3CODE_NO_BROWSER: "0", - T3CODE_HOST: "0.0.0.0", - VITE_WS_URL: "ws://localhost:13773", - }, - serverOffset: 0, - webOffset: 0, - t3Home: "/tmp/my-t3", - noBrowser: true, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: "127.0.0.1", - port: 4222, - devUrl: undefined, - }); - - assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); - assert.equal(env.PORT, "5733"); - assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); - assert.equal(env.HOST, "127.0.0.1"); - assert.equal(env.T3CODE_PORT, "4222"); - assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); - assert.equal(env.T3CODE_MODE, undefined); - assert.equal(env.T3CODE_NO_BROWSER, undefined); - assert.equal(env.T3CODE_HOST, undefined); - assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); - }), - ); - - it.effect("defaults dev server mode to the higher backend port range", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev", - baseEnv: {}, - serverOffset: 0, - webOffset: 0, - t3Home: undefined, - noBrowser: undefined, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: undefined, - port: undefined, - devUrl: undefined, - }); - - assert.equal(env.T3CODE_PORT, "13773"); - assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); - assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); - }), - ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/mock-update-server.test.ts b/scripts/mock-update-server.test.ts index 94467ac9..218dcd22 100644 --- a/scripts/mock-update-server.test.ts +++ b/scripts/mock-update-server.test.ts @@ -4,7 +4,7 @@ import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { HttpClient, HttpRouter } from "effect/unstable/http"; -import { makeMockUpdateRouteLayer, resolveRootRealPath } from "./mock-update-server.ts"; +import { makeMockUpdateRouteLayer } from "./mock-update-server.ts"; const withMockUpdateServer = (rootRealPath: string, effect: Effect.Effect) => effect.pipe( @@ -101,19 +101,4 @@ it.layer(NodeServices.layer)("mock-update-server", (it) => { ); }), ); - - it.effect("falls back to the resolved path when the configured root does not exist yet", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parent = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "mock-update-server-missing-root-", - }); - const missingRoot = path.join(parent, "release-mock"); - - const resolved = yield* resolveRootRealPath(missingRoot); - - assert.equal(resolved, missingRoot); - }), - ); }); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index fc8ee93f..8062f01b 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -10,21 +10,8 @@ interface MockUpdateServerConfig { readonly rootRealPath: string; } -export const resolveRootRealPath = (resolvedRoot: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem - .realPath(resolvedRoot) - .pipe( - Effect.catch((error) => - error._tag === "PlatformError" && error.reason?._tag === "NotFound" - ? Effect.succeed(resolvedRoot) - : Effect.fail(error), - ), - ); - }); - const resolveMockUpdateServerConfig = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* Config.all({ port: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.withDefault(3000)), @@ -37,7 +24,7 @@ const resolveMockUpdateServerConfig = Effect.gen(function* () { return { port: config.port, - rootRealPath: yield* resolveRootRealPath(resolvedRoot), + rootRealPath: yield* fileSystem.realPath(resolvedRoot), } satisfies MockUpdateServerConfig; }); diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 0d95b494..41f948c6 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -24,6 +24,7 @@ const workspaceFiles = [ "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", + "packages/effect-acp/package.json", "scripts/package.json", ] as const;