diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c4595..166f0109b6e 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -58,6 +58,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "acpRegistry") { + return ( + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198e..e3159b4b1d0 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -35,6 +35,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "acpRegistry") return "ACP"; return provider.instanceId; } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 34853209dbd..2759f0588d5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, + [WS_METHODS.serverListAcpRegistry]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts new file mode 100644 index 00000000000..a5e679f16b4 --- /dev/null +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -0,0 +1,194 @@ +/** + * AcpRegistryDriver — generic ACP catalog driver. + * + * One driver kind covers Gemini, Copilot, Pi, Hermes, Qwen, Kimi, and any + * custom ACP stdio agent. Launch command/args live on the instance config. + * + * @module provider/Drivers/AcpRegistryDriver + */ +import { + AcpRegistrySettings, + parseAcpLaunchArgs, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeGenericAcpAdapter } from "../Layers/GenericAcpAdapter.ts"; +import { + buildInitialAcpRegistryProviderSnapshot, + checkAcpRegistryProviderStatus, +} from "../Layers/AcpRegistryProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); +const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AcpRegistryDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const unsupportedTextGeneration = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Generic ACP providers do not support git text generation yet.", + }), + ); + +const makeTextGeneration = () => ({ + generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"), + generatePrContent: () => unsupportedTextGeneration("generatePrContent"), + generateBranchName: () => unsupportedTextGeneration("generateBranchName"), + generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"), +}); + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AcpRegistryDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ACP Registry", + supportsMultipleInstances: true, + }, + configSchema: AcpRegistrySettings, + defaultConfig: (): AcpRegistrySettings => decodeAcpRegistrySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AcpRegistrySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.command, + env: processEnv, + }); + + const adapter = yield* makeGenericAcpAdapter( + { + enabled: effectiveConfig.enabled, + command: effectiveConfig.command.trim() || "acp", + args: parseAcpLaunchArgs(effectiveConfig.launchArgs), + }, + { + provider: DRIVER_KIND, + instanceId, + environment: processEnv, + readyReason: "ACP session ready", + ...(effectiveConfig.authMethodId.trim() + ? { authMethodId: effectiveConfig.authMethodId.trim() } + : {}), + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }, + ); + + const checkProvider = checkAcpRegistryProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAcpRegistryProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build ACP Registry snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts b/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts new file mode 100644 index 00000000000..5cf09488f0c --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryProvider.test.ts @@ -0,0 +1,68 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { AcpRegistrySettings } from "@t3tools/contracts"; + +import { + buildInitialAcpRegistryProviderSnapshot, + checkAcpRegistryProviderStatus, +} from "./AcpRegistryProvider.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +describe("buildInitialAcpRegistryProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeSettings({ enabled: false, catalogId: "gemini", command: "gemini" }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.displayName).toBe("Gemini"); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("asks for a launch command when none is configured", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeSettings({ enabled: true, catalogId: "gemini" }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.message).toMatch(/launch command/i); + expect(snapshot.message).toContain("gemini"); + }), + ); + + it.effect("returns a pending snapshot while probing a configured command", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAcpRegistryProviderSnapshot( + decodeSettings({ enabled: true, catalogId: "gemini", command: "gemini" }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Checking ACP"); + }), + ); +}); + +it.layer(NodeServices.layer)("checkAcpRegistryProviderStatus", (it) => { + it.effect("reports the command as missing when it does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkAcpRegistryProviderStatus( + decodeSettings({ + enabled: true, + catalogId: "gemini", + command: "/definitely/not/installed/gemini-acp", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH/i); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AcpRegistryProvider.ts b/apps/server/src/provider/Layers/AcpRegistryProvider.ts new file mode 100644 index 00000000000..5ada18e3c58 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpRegistryProvider.ts @@ -0,0 +1,261 @@ +import { + type AcpRegistrySettings, + featuredAgentById, + parseAcpLaunchArgs, + ProviderDriverKind, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + buildGenericAcpSpawnInput, + makeGenericAcpRuntime, + resolveGenericAcpModelId, +} from "../acp/GenericAcpSupport.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); +const ACP_PRESENTATION = { + displayName: "ACP", + badgeLabel: "ACP", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const DEFAULT_MODELS: ReadonlyArray = [ + { + slug: "default", + name: "Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +function installHintForSettings(settings: AcpRegistrySettings): string { + return ( + featuredAgentById(settings.catalogId)?.installHint ?? + "Install an ACP-speaking CLI and set its command." + ); +} + +function presentationFor(settings: AcpRegistrySettings) { + return { + ...ACP_PRESENTATION, + displayName: featuredAgentById(settings.catalogId)?.label ?? ACP_PRESENTATION.displayName, + }; +} + +function modelsFromSettings( + customModels: ReadonlyArray | undefined, +): ReadonlyArray { + return providerModelsFromSettings(DEFAULT_MODELS, customModels ?? [], EMPTY_CAPABILITIES); +} + +function modelsFromSessionSetup( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveGenericAcpModelId(model.modelId, DRIVER_KIND); + if (!slug || seen.has(slug)) return undefined; + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +export function buildInitialAcpRegistryProviderSnapshot( + settings: AcpRegistrySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = modelsFromSettings(settings.customModels); + const command = settings.command.trim(); + const enabled = settings.enabled && command.length > 0; + + if (!settings.enabled) { + return buildServerProvider({ + presentation: presentationFor(settings), + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "This ACP instance is disabled.", + }, + }); + } + + return buildServerProvider({ + presentation: presentationFor(settings), + enabled, + checkedAt, + models, + probe: { + installed: enabled, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: enabled + ? "Checking ACP agent availability..." + : `Configure a launch command. ${installHintForSettings(settings)}`, + }, + }); + }); +} + +const discoverModelsViaAcp = (settings: AcpRegistrySettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeGenericAcpRuntime({ + spawn: buildGenericAcpSpawnInput( + { + command: settings.command.trim(), + args: parseAcpLaunchArgs(settings.launchArgs), + }, + process.cwd(), + environment, + ), + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + ...(settings.authMethodId.trim() ? { authMethodId: settings.authMethodId.trim() } : {}), + }); + const started = yield* acp.start(); + return modelsFromSessionSetup(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runVersionCommand = (settings: AcpRegistrySettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = settings.command.trim(); + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkAcpRegistryProviderStatus = Effect.fn("checkAcpRegistryProviderStatus")( + function* ( + settings: AcpRegistrySettings, + environment: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + > { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = modelsFromSettings(settings.customModels); + const presentation = presentationFor(settings); + const command = settings.command.trim(); + + if (!settings.enabled || command.length === 0) { + return buildServerProvider({ + presentation, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + command.length === 0 + ? `Configure a launch command. ${installHintForSettings(settings)}` + : "This ACP instance is disabled.", + }, + }); + } + + const versionResult = yield* runVersionCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult) && isCommandMissingCause(versionResult.failure)) { + return buildServerProvider({ + presentation, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `${command} is not installed or not on PATH. ${installHintForSettings(settings)}`, + }, + }); + } + + const version = + Result.isSuccess(versionResult) && Option.isSome(versionResult.success) + ? parseGenericCliVersion( + `${versionResult.success.value.stdout}\n${versionResult.success.value.stderr}`, + ) + : null; + + const discovered = yield* discoverModelsViaAcp(settings, environment).pipe( + Effect.timeoutOption(ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.option, + ); + const discoveredModels = + discovered._tag === "Some" && discovered.value._tag === "Some" ? discovered.value.value : []; + const models = + discoveredModels.length > 0 + ? providerModelsFromSettings(discoveredModels, settings.customModels, EMPTY_CAPABILITIES) + : fallbackModels; + + return buildServerProvider({ + presentation, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: + discoveredModels.length > 0 + ? "ACP session ready." + : "ACP command found. Models will load when a session starts.", + }, + }); + }, +); diff --git a/apps/server/src/provider/Layers/GenericAcpAdapter.test.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.test.ts new file mode 100644 index 00000000000..4672a90c3d9 --- /dev/null +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.test.ts @@ -0,0 +1,124 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { + genericAcpPromptSettlementBelongsToContext, + makeGenericAcpAdapter, +} from "./GenericAcpAdapter.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; +const driverKind = ProviderDriverKind.make("acpRegistry"); +const instanceId = ProviderInstanceId.make("acpRegistry_gemini"); + +const adapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-acp-registry-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it("requires a settlement to match the live ACP turn", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse( + genericAcpPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + genericAcpPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + +it.layer(adapterTestLayer)("GenericAcpAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("acp-registry-mock-thread"); + const adapter = yield* makeGenericAcpAdapter( + { + enabled: true, + command: mockAgentCommand, + args: [mockAgentPath], + }, + { + provider: driverKind, + instanceId, + }, + ).pipe(Effect.orDie); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: driverKind, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "acpRegistry"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello acp", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((event) => event.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "turn.completed", + ] as const); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/GenericAcpAdapter.ts b/apps/server/src/provider/Layers/GenericAcpAdapter.ts new file mode 100644 index 00000000000..cac7143a916 --- /dev/null +++ b/apps/server/src/provider/Layers/GenericAcpAdapter.ts @@ -0,0 +1,1436 @@ +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +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 FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyGenericAcpModelSelection, + buildGenericAcpSpawnInput, + currentGenericAcpModelIdFromSessionSetup, + makeGenericAcpRuntime, + resolveGenericAcpModelId, + type GenericAcpSpawnSettings, +} from "../acp/GenericAcpSupport.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const GENERIC_ACP_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface GenericAcpAdapterSettings extends GenericAcpSpawnSettings { + readonly enabled: boolean; +} + +export interface GenericAcpAdapterOptions { + readonly provider: ProviderDriverKind; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + readonly readyReason?: string; + readonly authMethodId?: string; + readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface GenericAcpSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: GenericAcpSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: GenericAcpSessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: GenericAcpSessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseGenericAcpResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== GENERIC_ACP_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + if (response === undefined) { + return null; + } + return response.stopReason; +} + +export function genericAcpPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeGenericAcpAdapter( + settings: GenericAcpAdapterSettings, + options: GenericAcpAdapterOptions, +) { + return Effect.gen(function* () { + const PROVIDER = options.provider; + const readyReason = options.readyReason ?? "ACP session ready"; + const boundInstanceId = options.instanceId ?? ProviderInstanceId.make("acpRegistry"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + 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 randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate ACP runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process ACP callback.", + cause, + }), + ), + ); + + 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 settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = genericAcpPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native ACP notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: GenericAcpSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + 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: GenericAcpSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(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: ProviderAdapterShape["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 = path.resolve(input.cwd.trim()); + if (!settings.enabled || settings.command.trim().length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "ACP command is not configured.", + }); + } + const selectedModel = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + 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), + ); + + const resumeSessionId = parseGenericAcpResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeGenericAcpRuntime({ + spawn: buildGenericAcpSpawnInput(settings, cwd, options.environment), + ...(options.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(options.authMethodId?.trim() ? { authMethodId: options.authMethodId.trim() } : {}), + ...(options.clientCapabilities + ? { clientCapabilities: options.clientCapabilities } + : {}), + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + 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.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + 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(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + 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, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = selectedModel?.model + ? resolveGenericAcpModelId(selectedModel.model, PROVIDER) + : undefined; + const boundModelId = yield* applyGenericAcpModelSelection({ + runtime: acp, + currentModelId: currentGenericAcpModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveGenericAcpModelId(boundModelId, PROVIDER) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: GENERIC_ACP_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: GenericAcpSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process ACP runtime notification.", { cause }), + ), + 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: readyReason }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveGenericAcpModelId(turnModelSelection.model, PROVIDER) + : undefined; + const currentModelId = yield* applyGenericAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + 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, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveGenericAcpModelId(currentModelId, PROVIDER) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "ACP prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "ACP prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "ACP session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "ACP session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "ACP session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "ACP prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( + threadId, + turnId, + ) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: ProviderAdapterShape["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: ProviderAdapterShape["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: "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); + }); + + const readThread: ProviderAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).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 ProviderAdapterShape; + }); +} diff --git a/apps/server/src/provider/acp/AcpRegistryCatalog.test.ts b/apps/server/src/provider/acp/AcpRegistryCatalog.test.ts new file mode 100644 index 00000000000..0024f2fbdb0 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryCatalog.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { AcpRegistryIndex } from "@t3tools/contracts"; + +import { + catalogEntryFromRegistryAgent, + featuredCatalogEntries, + mergeAcpRegistryCatalog, +} from "./AcpRegistryCatalog.ts"; + +const decodeIndex = Schema.decodeUnknownSync(AcpRegistryIndex); + +describe("featuredCatalogEntries", () => { + it("includes Gemini, Copilot, Pi, and custom ACP", () => { + expect(featuredCatalogEntries().map((agent) => agent.id)).toEqual( + expect.arrayContaining(["gemini", "github-copilot-cli", "pi-acp", "custom"]), + ); + expect(featuredCatalogEntries().find((agent) => agent.id === "gemini")?.launch).toEqual({ + command: "gemini", + args: ["--acp"], + }); + expect(featuredCatalogEntries().find((agent) => agent.id === "custom")?.launch).toBeNull(); + }); +}); + +describe("catalogEntryFromRegistryAgent", () => { + it("prefers featured launch specs over live registry npx wrappers", () => { + const entry = catalogEntryFromRegistryAgent({ + id: "gemini", + name: "Gemini CLI", + version: "0.54.4", + description: "Google's official CLI for Gemini", + distribution: { + npx: { package: "@google/gemini-cli@0.54.4", args: ["--acp"] }, + }, + }); + + expect(entry.featured).toBe(true); + expect(entry.launch).toEqual({ command: "gemini", args: ["--acp"] }); + expect(entry.version).toBe("0.54.4"); + }); + + it("maps npx-only registry agents without downloading binaries", () => { + const entry = catalogEntryFromRegistryAgent({ + id: "some-new-agent", + name: "Some New Agent", + description: "A new ACP agent", + distribution: { + npx: { package: "@example/some-agent", args: ["--acp"] }, + }, + }); + + expect(entry.featured).toBe(false); + expect(entry.distributionType).toBe("npx"); + expect(entry.launch).toEqual({ + command: "npx", + args: ["-y", "@example/some-agent", "--acp"], + }); + }); + + it("marks binary-only registry agents as unsupported", () => { + const entry = catalogEntryFromRegistryAgent({ + id: "binary-only", + name: "Binary Only", + distribution: { + binary: { darwin: "https://example.com/agent" }, + }, + }); + + expect(entry.distributionType).toBe("unsupported"); + expect(entry.launch).toBeNull(); + }); +}); + +describe("mergeAcpRegistryCatalog", () => { + it("returns featured agents when the live index is unavailable", () => { + const featured = featuredCatalogEntries(); + expect(mergeAcpRegistryCatalog(featured, null).agents).toEqual(featured); + }); + + it("appends non-featured live registry agents after featured rows", () => { + const featured = featuredCatalogEntries(); + const index = decodeIndex({ + version: "1.0.0", + agents: [ + { + id: "gemini", + name: "Gemini CLI", + version: "0.54.4", + distribution: { + npx: { package: "@google/gemini-cli@0.54.4", args: ["--acp"] }, + }, + }, + { + id: "fresh-agent", + name: "Fresh Agent", + distribution: { + uvx: { package: "fresh-agent", args: ["acp"] }, + }, + }, + ], + }); + + const merged = mergeAcpRegistryCatalog(featured, index); + expect(merged.registryVersion).toBe("1.0.0"); + expect(merged.agents[0]?.id).toBe("gemini"); + expect(merged.agents[0]?.featured).toBe(true); + expect(merged.agents.find((agent) => agent.id === "fresh-agent")).toMatchObject({ + featured: false, + distributionType: "uvx", + launch: { command: "uvx", args: ["fresh-agent", "acp"] }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/AcpRegistryCatalog.ts b/apps/server/src/provider/acp/AcpRegistryCatalog.ts new file mode 100644 index 00000000000..694c6e313a0 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryCatalog.ts @@ -0,0 +1,151 @@ +/** + * Resolve the ACP catalog: featured one-click agents plus the live ACP registry. + * + * Featured rows always win on id collision. Live registry entries that only + * ship platform binaries (no npx/uvx) are listed as unsupported so T3 never + * downloads remote executables. + * + * @module provider/acp/AcpRegistryCatalog + */ +import { + ACP_FEATURED_AGENTS, + ACP_REGISTRY_INDEX_URL, + AcpRegistryIndex, + defaultLaunchForFeaturedAgent, + featuredAgentById, + type AcpRegistryCatalogEntry, + type AcpRegistryIndexAgent, + type AcpRegistryListResult, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +const decodeIndex = Schema.decodeUnknownEffect(AcpRegistryIndex); + +function featuredEntry(agent: (typeof ACP_FEATURED_AGENTS)[number]): AcpRegistryCatalogEntry { + const launch = defaultLaunchForFeaturedAgent(agent) ?? null; + return { + id: agent.id, + label: agent.label, + description: agent.description, + featured: true, + ...(agent.docsUrl ? { docsUrl: agent.docsUrl } : {}), + installHint: agent.installHint, + iconKey: agent.iconKey, + distributionType: launch ? (agent.local ? "local" : agent.npx ? "npx" : "uvx") : "unsupported", + launch, + }; +} + +function registryEntry(agent: AcpRegistryIndexAgent): AcpRegistryCatalogEntry { + const featured = featuredAgentById(agent.id); + if (featured) { + return { + ...featuredEntry(featured), + ...(agent.version ? { version: agent.version } : {}), + ...(agent.icon ? { iconUrl: agent.icon } : {}), + ...(agent.website || agent.repository + ? { docsUrl: featured.docsUrl ?? agent.website ?? agent.repository } + : {}), + }; + } + + if (agent.distribution.npx) { + return { + id: agent.id, + label: agent.name, + description: agent.description?.trim() || agent.name, + featured: false, + ...(agent.website || agent.repository ? { docsUrl: agent.website ?? agent.repository } : {}), + installHint: `npx -y ${agent.distribution.npx.package}`, + iconKey: "acpRegistry", + ...(agent.icon ? { iconUrl: agent.icon } : {}), + ...(agent.version ? { version: agent.version } : {}), + distributionType: "npx", + launch: { + command: "npx", + args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], + }, + }; + } + + if (agent.distribution.uvx) { + return { + id: agent.id, + label: agent.name, + description: agent.description?.trim() || agent.name, + featured: false, + ...(agent.website || agent.repository ? { docsUrl: agent.website ?? agent.repository } : {}), + installHint: `uvx ${agent.distribution.uvx.package}`, + iconKey: "acpRegistry", + ...(agent.icon ? { iconUrl: agent.icon } : {}), + ...(agent.version ? { version: agent.version } : {}), + distributionType: "uvx", + launch: { + command: "uvx", + args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], + }, + }; + } + + return { + id: agent.id, + label: agent.name, + description: agent.description?.trim() || agent.name, + featured: false, + ...(agent.website || agent.repository ? { docsUrl: agent.website ?? agent.repository } : {}), + installHint: "Install this agent's CLI locally, then add a custom ACP instance.", + iconKey: "acpRegistry", + ...(agent.icon ? { iconUrl: agent.icon } : {}), + ...(agent.version ? { version: agent.version } : {}), + distributionType: "unsupported", + launch: null, + }; +} + +export function featuredCatalogEntries(): ReadonlyArray { + return ACP_FEATURED_AGENTS.map(featuredEntry); +} + +export function catalogEntryFromRegistryAgent( + agent: AcpRegistryIndexAgent, +): AcpRegistryCatalogEntry { + return registryEntry(agent); +} + +export function mergeAcpRegistryCatalog( + featured: ReadonlyArray, + index: AcpRegistryIndex | null | undefined, +): AcpRegistryListResult { + if (!index) { + return { agents: featured }; + } + + const featuredIds = new Set(featured.map((agent) => agent.id)); + const enrichedFeatured = featured.map((entry) => { + const remote = index.agents.find((agent) => agent.id === entry.id); + return remote ? catalogEntryFromRegistryAgent(remote) : entry; + }); + const extra = index.agents + .filter((agent) => !featuredIds.has(agent.id)) + .map(catalogEntryFromRegistryAgent); + + return { + registryVersion: index.version, + agents: [...enrichedFeatured, ...extra], + }; +} + +export const listAcpRegistryCatalog = Effect.fn("listAcpRegistryCatalog")(function* () { + const featured = featuredCatalogEntries(); + const httpClient = yield* HttpClient.HttpClient; + const remote = yield* httpClient.execute(HttpClientRequest.get(ACP_REGISTRY_INDEX_URL)).pipe( + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeIndex), + Effect.timeout("8 seconds"), + Effect.option, + ); + + return mergeAcpRegistryCatalog(featured, remote._tag === "Some" ? remote.value : null); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9..8a1f6841289 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,7 +68,7 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -541,15 +541,23 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + const advertisedAuthMethods = initializeResult.authMethods ?? []; + const requestedAuthMethodId = options.authMethodId?.trim(); + const authMethodId = + requestedAuthMethodId || + advertisedAuthMethods.find((method) => method.id === "none")?.id || + advertisedAuthMethods[0]?.id; + if (authMethodId) { + const authenticatePayload = { + methodId: authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/GenericAcpSupport.ts b/apps/server/src/provider/acp/GenericAcpSupport.ts new file mode 100644 index 00000000000..d0408c76db4 --- /dev/null +++ b/apps/server/src/provider/acp/GenericAcpSupport.ts @@ -0,0 +1,99 @@ +/** + * Generic ACP runtime — spawn any ACP stdio agent. + * + * Cursor and Grok keep provider-specific auth, spawn args, and extensions. + * Catalog agents (Gemini, Copilot, Pi, custom ACP) share this runtime. + * + * @module provider/acp/GenericAcpSupport + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +export interface GenericAcpSpawnSettings { + readonly command: string; + readonly args: ReadonlyArray; +} + +export interface GenericAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly spawn: AcpSessionRuntime.AcpSpawnInput; +} + +export function buildGenericAcpSpawnInput( + settings: GenericAcpSpawnSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: settings.command, + args: [...settings.args], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeGenericAcpRuntime = ( + input: GenericAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer(input).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveGenericAcpModelId( + model: string | null | undefined, + driverKind: ProviderDriverKind = ProviderDriverKind.make("acpRegistry"), +): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "default"; + return normalizeModelSlug(base, driverKind) ?? "default"; +} + +export function currentGenericAcpModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyGenericAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..737a7865852 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AcpRegistryDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListAcpRegistry]: (_input) => + observeRpcEffect(WS_METHODS.serverListAcpRegistry, listAcpRegistryCatalog(), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1f..298aa46cf1f 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ACPRegistryIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("acpRegistry")]: ACPRegistryIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index fdffa9a190e..563d376f8c1 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -1,11 +1,54 @@ +import { + defaultLaunchForFeaturedAgent, + featuredAgentById, + formatAcpLaunchArgs, + ProviderDriverKind, +} from "@t3tools/contracts"; + export type WizardNavigation = | { readonly kind: "navigate"; readonly step: number } | { readonly kind: "blocked"; readonly step: number; readonly error: string }; const IDENTITY_STEP = 1; +export const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); +export const ACP_PICKER_PREFIX = "acp:"; export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const; +export function acpPickerValue(catalogId: string): string { + return `${ACP_PICKER_PREFIX}${catalogId}`; +} + +export function parseAddProviderPickerValue(value: string): { + readonly driver: ProviderDriverKind; + readonly catalogId: string | undefined; +} { + if (value.startsWith(ACP_PICKER_PREFIX)) { + return { + driver: ACP_REGISTRY_DRIVER, + catalogId: value.slice(ACP_PICKER_PREFIX.length), + }; + } + return { driver: ProviderDriverKind.make(value), catalogId: undefined }; +} + +export function configDraftForFeaturedAgent(catalogId: string): Record { + const agent = featuredAgentById(catalogId); + if (!agent || agent.id === "custom") { + return {}; + } + const launch = defaultLaunchForFeaturedAgent(agent); + return { + catalogId: agent.id, + ...(launch + ? { + command: launch.command, + launchArgs: formatAcpLaunchArgs(launch.args), + } + : {}), + }; +} + /** * Resolve navigation within the add-provider wizard. * diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index 594d2e4537e..251f4fc8f80 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic"; +import { + acpPickerValue, + configDraftForFeaturedAgent, + parseAddProviderPickerValue, + resolveWizardNavigation, +} from "./AddProviderInstanceDialog.logic"; describe("resolveWizardNavigation", () => { const invalidId = { instanceIdError: "Instance ID is required." }; @@ -42,3 +47,26 @@ describe("resolveWizardNavigation", () => { expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 }); }); }); + +describe("ACP catalog picker", () => { + it("parses featured ACP picker values onto the registry driver", () => { + expect(acpPickerValue("gemini")).toBe("acp:gemini"); + expect(parseAddProviderPickerValue("acp:gemini")).toEqual({ + driver: "acpRegistry", + catalogId: "gemini", + }); + expect(parseAddProviderPickerValue("codex")).toEqual({ + driver: "codex", + catalogId: undefined, + }); + }); + + it("prefills Gemini launch config without downloading a binary", () => { + expect(configDraftForFeaturedAgent("gemini")).toEqual({ + catalogId: "gemini", + command: "gemini", + launchArgs: "--acp", + }); + expect(configDraftForFeaturedAgent("custom")).toEqual({}); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 158908b5e94..044e7451da4 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -4,6 +4,8 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { + ACP_FEATURED_AGENTS, + type AcpRegistryCatalogIconKey, ProviderInstanceId, ProviderDriverKind, type EnvironmentId, @@ -27,11 +29,15 @@ import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; -import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; +import { DRIVER_OPTION_BY_VALUE, NATIVE_DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; import { AnimatedHeight } from "../AnimatedHeight"; import { + ACP_REGISTRY_DRIVER, ADD_PROVIDER_WIZARD_STEPS, + acpPickerValue, + configDraftForFeaturedAgent, + parseAddProviderPickerValue, resolveWizardNavigation, type WizardNavigation, } from "./AddProviderInstanceDialog.logic"; @@ -69,36 +75,15 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); -const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; +const DEFAULT_DRIVER_OPTION = NATIVE_DRIVER_OPTIONS[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; -interface ComingSoonDriverOption { - readonly value: ProviderDriverKind; - readonly label: string; - readonly icon: Icon; -} -const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ - { - value: ProviderDriverKind.make("githubCopilot"), - label: "Github Copilot", - icon: GithubCopilotIcon, - }, - { - value: ProviderDriverKind.make("gemini"), - label: "Gemini", - icon: Gemini, - }, - { - value: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - icon: ACPRegistryIcon, - }, - { - value: ProviderDriverKind.make("piAgent"), - label: "Pi Agent", - icon: PiAgentIcon, - }, -]; +function iconForCatalogKey(iconKey: AcpRegistryCatalogIconKey): Icon { + if (iconKey === "gemini") return Gemini; + if (iconKey === "githubCopilot") return GithubCopilotIcon; + if (iconKey === "piAgent") return PiAgentIcon; + return ACPRegistryIcon; +} /** * Validate an instance id against the same slug rules the server applies in @@ -133,6 +118,7 @@ export function AddProviderInstanceDialog({ const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); + const [catalogId, setCatalogId] = useState(undefined); const [label, setLabel] = useState(""); const [accentColor, setAccentColor] = useState(""); const [instanceIdOverride, setInstanceIdOverride] = useState(null); @@ -149,6 +135,10 @@ export function AddProviderInstanceDialog({ ); const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const featuredAgent = catalogId + ? ACP_FEATURED_AGENTS.find((agent) => agent.id === catalogId) + : undefined; + const pickerLabel = featuredAgent?.label ?? driverOption.label; const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); const driverSettingsFields = useMemo( () => deriveProviderSettingsFields(driverOption), @@ -156,8 +146,9 @@ export function AddProviderInstanceDialog({ ); const instanceIdError = validateInstanceId(instanceId, existingIds); const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; - const previewLabel = label.trim() || `${driverOption.label} Workspace`; - const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; + const previewLabel = label.trim() || `${pickerLabel} Workspace`; + const wizardStepSummaries = [pickerLabel, previewLabel, null] as const; + const pickerValue = catalogId ? acpPickerValue(catalogId) : driver; const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; const setConfigDraft = (config: Record | undefined) => { @@ -216,7 +207,7 @@ export function AddProviderInstanceDialog({ toastManager.add({ type: "success", title: "Provider instance added", - description: `${driverOption.label} instance '${instanceId}' was added.`, + description: `${pickerLabel} instance '${instanceId}' was added.`, }); onOpenChange(false); } catch (error) { @@ -235,8 +226,8 @@ export function AddProviderInstanceDialog({ Add provider instance - Configure an additional provider instance on {environmentLabel} — for example, a - second Codex install pointed at a different workspace. + Configure an additional provider instance on {environmentLabel} — native drivers, or + any ACP-speaking CLI such as Gemini, Copilot, or Pi. setDriver(ProviderDriverKind.make(value))} + value={pickerValue} + onValueChange={(value) => { + const parsed = parseAddProviderPickerValue(value); + setDriver(parsed.driver); + setCatalogId(parsed.catalogId); + if (parsed.catalogId) { + const agent = ACP_FEATURED_AGENTS.find( + (entry) => entry.id === parsed.catalogId, + ); + setLabel(agent?.label ?? ""); + const nextConfig = configDraftForFeaturedAgent(parsed.catalogId); + setConfigByDriver((existing) => { + const next = { ...existing }; + if (Object.keys(nextConfig).length === 0) { + delete next[parsed.driver]; + } else { + next[parsed.driver] = nextConfig; + } + return next; + }); + return; + } + if (parsed.driver !== ACP_REGISTRY_DRIVER) { + setLabel(""); + } + }} aria-labelledby="add-instance-driver-label" className="grid grid-cols-1 gap-2 sm:grid-cols-2" > - {DRIVER_OPTIONS.map((option) => { + {NATIVE_DRIVER_OPTIONS.map((option) => { const IconComponent = option.icon; return ( ); })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; + {ACP_FEATURED_AGENTS.map((agent) => { + const IconComponent = iconForCatalogKey(agent.iconKey); return ( - + - {option.label} + {agent.label} + + + - Coming Soon + ACP ); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb..f1da927f001 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -129,4 +129,15 @@ describe("ProviderSettingsForm helpers", () => { it("reads missing boolean config values from the supplied default", () => { expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true); }); + + it("derives ACP registry launch fields from the client definition schema", () => { + const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + + expect(acpRegistry).toBeDefined(); + expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ + "command", + "launchArgs", + "authMethodId", + ]); + }); }); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..b431d34eb4c 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AcpRegistrySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -7,7 +8,15 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ACPRegistryIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP", + icon: ACPRegistryIcon, + badgeLabel: "Early Access", + settingsSchema: AcpRegistrySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< @@ -76,6 +92,9 @@ export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< ); export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS; +export const NATIVE_DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS.filter( + (definition) => definition.value !== ProviderDriverKind.make("acpRegistry"), +); export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE; export type DriverOption = ProviderClientDefinition; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133..6c296963dee 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("acpRegistry"), + label: "ACP", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index 51277fd73d2..d4b38497ec8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [ACP](./user/providers-acp.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339..3c35f927a52 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and `acpRegistry` for other ACP-speaking CLIs. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d..2d2ce1cbafe 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ OpenCode, plus ACP catalog agents │ └────────────────────────────────────────────────┘ ``` @@ -106,11 +106,13 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a -scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves -an instance to its adapter, so `ProviderService` routes session and turn operations without knowing -which agent is behind them. See [providers.md](./providers.md). +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, OpenCode, and `acpRegistry`. Native drivers stay first-class. +`acpRegistry` covers every other ACP-speaking CLI through one generic adapter and a catalog of +launch specs. A driver declares its kind and config schema and creates a scoped adapter; +`ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance +to its adapter, so `ProviderService` routes session and turn operations without knowing which agent +is behind them. See [providers.md](./providers.md). ## Checkpointing diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03d..cdbeac059c1 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,21 +7,27 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: - -| Driver kind | Driver source | -| ------------- | --------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: + +| Driver kind | Driver source | +| ------------- | --------------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `acpRegistry` | [`Drivers/AcpRegistryDriver.ts`][acpregistry] | + +Native drivers stay first-class. `acpRegistry` is the Paseo-style extra-provider path: one generic +ACP adapter plus a featured catalog (Gemini, Copilot, Pi, …) and the live ACP registry index. +Adding another ACP-speaking CLI is a catalog row, not a new driver. T3 Code never downloads agent +binaries; the user installs the CLI and points the instance at its command. Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in -`apps/server/src/provider/Layers/` (`CodexAdapter.ts`, `ClaudeAdapter.ts`, and so on) and conform to -[`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's -transport, config, and event shapes are mapped. +`apps/server/src/provider/Layers/` (`CodexAdapter.ts`, `ClaudeAdapter.ts`, `GenericAcpAdapter.ts`, +and so on) and conform to [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see +how a specific agent's transport, config, and event shapes are mapped. ## Registry and routing @@ -81,6 +87,7 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[acpregistry]: ../../apps/server/src/provider/Drivers/AcpRegistryDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e..93b9d6fde02 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -53,6 +53,7 @@ to use, then authenticate it. | Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | | Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | | OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| ACP | Any ACP-speaking CLI (Gemini, Copilot, Pi, …) | your command | follow that CLI | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. @@ -75,6 +76,7 @@ authenticated shows its status in **Settings** and fails at session start with t to run. For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For Gemini, Copilot, Pi, and other ACP CLIs, see [ACP providers](./providers-acp.md). ## Next Steps diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md new file mode 100644 index 00000000000..9e18958d4a9 --- /dev/null +++ b/docs/user/providers-acp.md @@ -0,0 +1,54 @@ +# ACP providers + +T3 Code can run any coding agent that speaks [ACP](https://agentclientprotocol.com) over stdio. +Native drivers (Codex, Claude, Cursor, Grok, OpenCode) stay first-class. Everything else — Gemini, +GitHub Copilot, Pi, Hermes, Qwen, Kimi, or a custom CLI — is one ACP provider instance with a launch +command. + +T3 Code does not download agent binaries. Install the CLI yourself, then point T3 Code at it. + +## Add a featured agent + +1. Open **Settings** → **Providers**. +2. Select **Add provider instance**. +3. Choose Gemini, GitHub Copilot, Pi Agent, or another ACP agent. +4. Confirm the command and launch arguments, then add the instance. + +Featured agents prefill the usual launch spec: + +| Agent | Command | Arguments | +| -------------- | --------- | ----------------------------- | +| Gemini | `gemini` | `--acp` | +| GitHub Copilot | `copilot` | `--acp` | +| Pi Agent | `pi-acp` | | +| Hermes | `hermes` | `acp` | +| Qwen Code | `qwen` | `--acp --experimental-skills` | +| Kimi CLI | `kimi` | `acp` | + +Install the matching CLI on the machine running T3 Code, then authenticate it the way that CLI +expects. Refresh provider status after install. + +## Custom ACP + +Choose **Custom ACP** when the agent is not in the featured list. Set: + +- **Command** — the binary or launcher that speaks ACP on stdio +- **Launch arguments** — extra args, for example `--acp` +- **Auth method** — leave blank unless the agent requires a specific ACP authenticate method id + +You can also use `npx` or `uvx` as the command if that is how you launch the agent. + +## Status and models + +T3 Code probes the command, then starts a short ACP session to discover models. If the CLI is +missing, the instance shows an error with the install hint. If the CLI is present but model +discovery fails, the instance stays usable and models load when you start a thread. + +Git commit and pull-request text generation still uses a native provider. ACP instances do not +generate that text yet. + +## Related + +- [Install T3 Code](./install.md) +- [Codex](./providers-codex.md) +- [Claude](./providers-claude.md) diff --git a/packages/contracts/src/acpRegistry.test.ts b/packages/contracts/src/acpRegistry.test.ts new file mode 100644 index 00000000000..be15ced4df2 --- /dev/null +++ b/packages/contracts/src/acpRegistry.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + ACP_FEATURED_AGENTS, + AcpRegistryIndex, + defaultLaunchForFeaturedAgent, + featuredAgentById, + formatAcpLaunchArgs, + parseAcpLaunchArgs, +} from "./acpRegistry.ts"; + +const decodeIndex = Schema.decodeUnknownSync(AcpRegistryIndex); + +describe("ACP featured catalog", () => { + it("includes Gemini, Copilot, Pi, and a custom ACP slot", () => { + expect(ACP_FEATURED_AGENTS.map((agent) => agent.id)).toEqual( + expect.arrayContaining(["gemini", "github-copilot-cli", "pi-acp", "custom"]), + ); + }); + + it("resolves featured launch specs without downloading binaries", () => { + expect(defaultLaunchForFeaturedAgent(featuredAgentById("gemini")!)).toEqual({ + command: "gemini", + args: ["--acp"], + }); + expect(defaultLaunchForFeaturedAgent(featuredAgentById("pi-acp")!)).toEqual({ + command: "pi-acp", + args: [], + }); + expect(defaultLaunchForFeaturedAgent(featuredAgentById("custom")!)).toBeUndefined(); + }); +}); + +describe("parseAcpLaunchArgs", () => { + it("splits quoted arguments", () => { + expect(parseAcpLaunchArgs(`--acp --model "gemini 3"`)).toEqual([ + "--acp", + "--model", + "gemini 3", + ]); + expect(formatAcpLaunchArgs(["--acp", "--model", "gemini 3"])).toBe('--acp --model "gemini 3"'); + }); + + it("treats empty input as no args", () => { + expect(parseAcpLaunchArgs("")).toEqual([]); + expect(parseAcpLaunchArgs(" ")).toEqual([]); + }); +}); + +describe("AcpRegistryIndex", () => { + it("decodes the official registry shape", () => { + const decoded = decodeIndex({ + version: "1.0.0", + agents: [ + { + id: "gemini", + name: "Gemini CLI", + version: "0.54.4", + description: "Google's official CLI for Gemini", + distribution: { + npx: { package: "@google/gemini-cli@0.54.4", args: ["--acp"] }, + }, + }, + ], + }); + + expect(decoded.agents[0]?.id).toBe("gemini"); + expect(decoded.agents[0]?.distribution.npx?.package).toBe("@google/gemini-cli@0.54.4"); + }); +}); diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts new file mode 100644 index 00000000000..304d62b2b51 --- /dev/null +++ b/packages/contracts/src/acpRegistry.ts @@ -0,0 +1,249 @@ +/** + * ACP Registry contracts. + * + * T3 Code's extra-provider path follows Paseo: native drivers stay first-class + * (Codex, Claude, Cursor, Grok, OpenCode), and every other ACP-speaking CLI is + * one generic `acpRegistry` driver plus a catalog of launch specs. + * + * Featured entries are the in-app one-click list (Gemini, Copilot, Pi, …). + * The live ACP registry index is the same JSON clients fetch from + * `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json`. + * Adding another agent is a catalog row, not a new driver. + * + * @module acpRegistry + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const ACP_REGISTRY_DRIVER_KIND = "acpRegistry" as const; +export const ACP_REGISTRY_INDEX_URL = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; + +export const AcpRegistryCatalogIconKey = Schema.Literals([ + "acpRegistry", + "gemini", + "githubCopilot", + "piAgent", +]); +export type AcpRegistryCatalogIconKey = typeof AcpRegistryCatalogIconKey.Type; + +export const AcpRegistryDistributionType = Schema.Literals(["local", "npx", "uvx", "unsupported"]); +export type AcpRegistryDistributionType = typeof AcpRegistryDistributionType.Type; + +export const AcpRegistryLaunchSpec = Schema.Struct({ + command: TrimmedNonEmptyString, + args: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type AcpRegistryLaunchSpec = typeof AcpRegistryLaunchSpec.Type; + +export interface AcpRegistryFeaturedLaunch { + readonly command: string; + readonly args: ReadonlyArray; +} + +export interface AcpRegistryFeaturedAgent { + readonly id: string; + readonly label: string; + readonly description: string; + readonly docsUrl?: string; + readonly installHint: string; + readonly iconKey: AcpRegistryCatalogIconKey; + readonly local?: AcpRegistryFeaturedLaunch; + readonly npx?: { readonly package: string; readonly args: ReadonlyArray }; + readonly uvx?: { readonly package: string; readonly args: ReadonlyArray }; +} + +/** + * Curated one-click ACP agents. Keep this list small and obvious; the live + * registry RPC is how we pick up the long tail without a code change. + */ +export const ACP_FEATURED_AGENTS: ReadonlyArray = [ + { + id: "gemini", + label: "Gemini", + description: "Google's official Gemini CLI.", + docsUrl: "https://geminicli.com", + installHint: "npm i -g @google/gemini-cli && gemini", + iconKey: "gemini", + local: { command: "gemini", args: ["--acp"] }, + npx: { package: "@google/gemini-cli", args: ["--acp"] }, + }, + { + id: "github-copilot-cli", + label: "GitHub Copilot", + description: "GitHub Copilot CLI over ACP.", + docsUrl: "https://github.com/features/copilot/cli/", + installHint: "npm i -g @github/copilot && copilot login", + iconKey: "githubCopilot", + local: { command: "copilot", args: ["--acp"] }, + npx: { package: "@github/copilot", args: ["--acp"] }, + }, + { + id: "pi-acp", + label: "Pi Agent", + description: "Pi coding agent through its ACP adapter.", + docsUrl: "https://pi.dev", + installHint: "npm i -g @mariozechner/pi-coding-agent && npm i -g pi-acp", + iconKey: "piAgent", + local: { command: "pi-acp", args: [] }, + npx: { package: "pi-acp", args: [] }, + }, + { + id: "hermes", + label: "Hermes", + description: "Nous Research Hermes agent over ACP.", + docsUrl: "https://hermes-agent.nousresearch.com", + installHint: "Install Hermes, then launch with `hermes acp`.", + iconKey: "acpRegistry", + local: { command: "hermes", args: ["acp"] }, + }, + { + id: "qwen-code", + label: "Qwen Code", + description: "Alibaba's Qwen coding assistant.", + docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", + installHint: "npm i -g @qwen-code/qwen-code", + iconKey: "acpRegistry", + local: { command: "qwen", args: ["--acp", "--experimental-skills"] }, + npx: { package: "@qwen-code/qwen-code", args: ["--acp", "--experimental-skills"] }, + }, + { + id: "kimi", + label: "Kimi CLI", + description: "Moonshot AI's Kimi coding assistant.", + docsUrl: "https://moonshotai.github.io/kimi-cli/", + installHint: "Install Kimi CLI, then launch with `kimi acp`.", + iconKey: "acpRegistry", + local: { command: "kimi", args: ["acp"] }, + }, + { + id: "custom", + label: "Custom ACP", + description: "Any agent that speaks ACP over stdio.", + installHint: "Point command and arguments at an ACP stdio binary.", + iconKey: "acpRegistry", + }, +]; + +export const AcpRegistryIndexAgent = Schema.Struct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + version: Schema.optional(TrimmedNonEmptyString), + description: Schema.optional(TrimmedNonEmptyString), + repository: Schema.optional(TrimmedNonEmptyString), + website: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(TrimmedNonEmptyString), + distribution: Schema.Struct({ + npx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(Schema.String)), + }), + ), + uvx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(Schema.String)), + }), + ), + binary: Schema.optional(Schema.Unknown), + }), +}); +export type AcpRegistryIndexAgent = typeof AcpRegistryIndexAgent.Type; + +export const AcpRegistryIndex = Schema.Struct({ + version: TrimmedNonEmptyString, + agents: Schema.Array(AcpRegistryIndexAgent), +}); +export type AcpRegistryIndex = typeof AcpRegistryIndex.Type; + +export const AcpRegistryCatalogEntry = Schema.Struct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + featured: Schema.Boolean, + docsUrl: Schema.optional(TrimmedNonEmptyString), + installHint: TrimmedNonEmptyString, + iconKey: AcpRegistryCatalogIconKey, + iconUrl: Schema.optional(TrimmedNonEmptyString), + version: Schema.optional(TrimmedNonEmptyString), + distributionType: AcpRegistryDistributionType, + launch: Schema.NullOr(AcpRegistryLaunchSpec), +}); +export type AcpRegistryCatalogEntry = typeof AcpRegistryCatalogEntry.Type; + +export const AcpRegistryListResult = Schema.Struct({ + registryVersion: Schema.optional(TrimmedNonEmptyString), + agents: Schema.Array(AcpRegistryCatalogEntry), +}); +export type AcpRegistryListResult = typeof AcpRegistryListResult.Type; + +export function featuredAgentById( + catalogId: string | null | undefined, +): AcpRegistryFeaturedAgent | undefined { + const id = catalogId?.trim(); + if (!id) return undefined; + return ACP_FEATURED_AGENTS.find((agent) => agent.id === id); +} + +export function defaultLaunchForFeaturedAgent( + agent: AcpRegistryFeaturedAgent, +): AcpRegistryFeaturedLaunch | undefined { + if (agent.local) return agent.local; + if (agent.npx) { + return { + command: "npx", + args: ["-y", agent.npx.package, ...agent.npx.args], + }; + } + if (agent.uvx) { + return { + command: "uvx", + args: [agent.uvx.package, ...agent.uvx.args], + }; + } + return undefined; +} + +/** + * Split a launch-args string the way Codex/Claude settings do: whitespace + * separated, with simple single/double quotes. + */ +export function parseAcpLaunchArgs(value: string | null | undefined): ReadonlyArray { + const input = value?.trim() ?? ""; + if (input.length === 0) return []; + + const args: string[] = []; + let current = ""; + let quote: "'" | '"' | undefined; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]!; + if (quote) { + if (char === quote) { + quote = undefined; + continue; + } + current += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + if (/\s/u.test(char)) { + if (current.length > 0) { + args.push(current); + current = ""; + } + continue; + } + current += char; + } + if (current.length > 0) args.push(current); + return args; +} + +export function formatAcpLaunchArgs(args: ReadonlyArray): string { + return args.map((arg) => (/\s/u.test(arg) ? `"${arg.replaceAll('"', '\\"')}"` : arg)).join(" "); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687b..0aa9ef3fe71 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ +export * from "./acpRegistry.ts"; export * from "./baseSchemas.ts"; export * from "./background.ts"; export * from "./auth.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd..4c0c4194a81 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ACP_REGISTRY_DRIVER_KIND]: "ACP", }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8d0f0e5b5e4..94011f12a48 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -66,6 +66,7 @@ import { OrchestrationRpcSchemas, OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; +import { AcpRegistryListResult } from "./acpRegistry.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { PullRequestActionInput, @@ -249,6 +250,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListAcpRegistry: "server.listAcpRegistry", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -343,6 +345,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListAcpRegistryRpc = Rpc.make(WS_METHODS.serverListAcpRegistry, { + payload: Schema.Struct({}), + success: AcpRegistryListResult, + error: EnvironmentAuthorizationError, +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -950,6 +958,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListAcpRegistryRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c8..905a691064a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -421,6 +421,52 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + catalogId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + command: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Command", + description: "Binary or launcher that speaks ACP on stdio.", + providerSettingsForm: { placeholder: "gemini", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Arguments passed after the command. Example: --acp", + providerSettingsForm: { placeholder: "--acp", clearWhenEmpty: "omit" }, + }), + ), + authMethodId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Auth method", + description: + "ACP authenticate method id. Leave blank to use the agent's advertised default.", + providerSettingsForm: { placeholder: "none", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["command", "launchArgs", "authMethodId"], + }, +); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe(