diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a7f845672cad..866bb251acb0 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -106,7 +106,7 @@ function withRealCodexHarness( const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); - const provider = harness.adapterHarness?.provider ?? "codex"; + const provider = (harness.adapterHarness?.provider ?? "codex") as Exclude; const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; yield* harness.engine.dispatch({ diff --git a/apps/server/package.json b/apps/server/package.json index 14dbe35bcba5..c1d441100cdb 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/ws": "^8.5.13", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", "tsdown": "catalog:", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 26ffa084a836..de96c9992649 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -11,7 +11,6 @@ import * as AcpError from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; -const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -19,152 +18,13 @@ const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHO const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; -const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const sessionId = "mock-session-1"; let currentModeId = "ask"; let currentModelId = "default"; -let parameterizedModelPicker = false; -let currentReasoning = "medium"; -let currentContext = "272k"; -let currentFast = false; const cancelledSessions = new Set(); -function logExit(reason: string): void { - if (!exitLogPath) { - return; - } - appendFileSync(exitLogPath, `${reason}\n`, "utf8"); -} - -process.once("SIGTERM", () => { - logExit("SIGTERM"); - process.exit(0); -}); - -process.once("SIGINT", () => { - logExit("SIGINT"); - process.exit(0); -}); - -process.once("exit", (code) => { - logExit(`exit:${code}`); -}); - function configOptions(): ReadonlyArray { - if (parameterizedModelPicker) { - const baseOptions: Array = [ - { - id: "mode", - name: "Mode", - category: "mode", - type: "select", - currentValue: currentModeId, - options: availableModes.map((mode) => ({ - value: mode.id, - name: mode.name, - ...(mode.description ? { description: mode.description } : {}), - })), - }, - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: currentModelId, - options: [ - { value: "default", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - { value: "gpt-5.4", name: "GPT-5.4" }, - { value: "claude-opus-4-6", name: "Opus 4.6" }, - ], - }, - ]; - - switch (currentModelId) { - case "gpt-5.4": - return [ - ...baseOptions, - { - id: "reasoning", - name: "Reasoning", - category: "thought_level", - type: "select", - currentValue: currentReasoning, - options: [ - { value: "none", name: "None" }, - { value: "low", name: "Low" }, - { value: "medium", name: "Medium" }, - { value: "high", name: "High" }, - { value: "extra-high", name: "Extra High" }, - ], - }, - { - id: "context", - name: "Context", - category: "model_config", - type: "select", - currentValue: currentContext, - options: [ - { value: "272k", name: "272K" }, - { value: "1m", name: "1M" }, - ], - }, - { - id: "fast", - name: "Fast", - category: "model_config", - type: "select", - currentValue: String(currentFast), - options: [ - { value: "false", name: "Off" }, - { value: "true", name: "Fast" }, - ], - }, - ]; - case "composer-2": - return [ - ...baseOptions, - { - id: "fast", - name: "Fast", - category: "model_config", - type: "select", - currentValue: String(currentFast), - options: [ - { value: "false", name: "Off" }, - { value: "true", name: "Fast" }, - ], - }, - ]; - case "claude-opus-4-6": - return [ - ...baseOptions, - { - id: "reasoning", - name: "Reasoning", - category: "thought_level", - type: "select", - currentValue: currentReasoning, - options: [ - { value: "low", name: "Low" }, - { value: "medium", name: "Medium" }, - { value: "high", name: "High" }, - ], - }, - { - id: "thinking", - name: "Thinking", - category: "model_config", - type: "boolean", - currentValue: true, - }, - ]; - default: - return baseOptions; - } - } - return [ { id: "model", @@ -210,14 +70,10 @@ function modeState(): AcpSchema.SessionModeState { const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; - yield* agent.handleInitialize((request) => - Effect.sync(() => { - parameterizedModelPicker = - request.clientCapabilities?._meta?.parameterizedModelPicker === true; - return { - protocolVersion: 1, - agentCapabilities: { loadSession: true }, - }; + yield* agent.handleInitialize(() => + Effect.succeed({ + protocolVersion: 1, + agentCapabilities: { loadSession: true }, }), ); @@ -264,21 +120,9 @@ const program = Effect.gen(function* () { }, ); } - if (request.configId === "mode" && typeof request.value === "string") { - currentModeId = request.value; - } if (request.configId === "model" && typeof request.value === "string") { currentModelId = request.value; } - if (request.configId === "reasoning" && typeof request.value === "string") { - currentReasoning = request.value; - } - if (request.configId === "context" && typeof request.value === "string") { - currentContext = request.value; - } - if (request.configId === "fast") { - currentFast = request.value === true || request.value === "true"; - } return { configOptions: configOptions(), }; @@ -287,7 +131,7 @@ const program = Effect.gen(function* () { yield* agent.handleCancel(({ sessionId }) => Effect.sync(() => { - cancelledSessions.add(String(sessionId ?? "mock-session-1")); + cancelledSessions.add(String(sessionId)); }), ); @@ -507,7 +351,7 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: promptResponseText ?? "hello from mock" }, + content: { type: "text", text: "hello from mock" }, }, }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index e7bce1134741..62079411b9cc 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -1,27 +1,13 @@ -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; -import { ServerSettingsError } from "@t3tools/contracts"; - import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( @@ -32,98 +18,159 @@ const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - agentPath, - [ - "#!/bin/sh", - ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - 'if [ "$1" != "acp" ]; then', - ' printf "%s\\n" "unexpected args: $*" >&2', - " exit 11", - "fi", - `exec bun ${JSON.stringify(mockAgentPath)}`, - "", - ].join("\n"), - "utf8", - ); - chmodSync(agentPath, 0o755); - return agentPath; -} - -function withFakeAcpAgent( - env: Record, - effect: Effect.Effect, -): Effect.Effect { +function makeFakeAgentBinary( + dir: string, + input: { + result: string; + requireModel?: string; + requireTrust?: boolean; + requireMode?: string; + stdinMustContain?: string; + stderr?: string; + exitCode?: number; + }, +) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); - const agentPath = makeAcpAgentWrapper(tempDir, env); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - - yield* serverSettings.updateSettings({ - providers: { - cursor: { - binaryPath: agentPath, - }, - }, - }); - - return yield* effect.pipe( - Effect.ensuring( - serverSettings - .updateSettings({ - providers: { - cursor: { - binaryPath: previousSettings.providers.cursor.binaryPath, - }, - }, - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring( - Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); - }), - ), - Effect.asVoid, - ), - ), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const agentPath = path.join(binDir, "agent"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + agentPath, + [ + "#!/bin/sh", + 'model=""', + 'seen_trust="0"', + 'mode=""', + "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--model" ]; then', + " shift", + ' model="$1"', + " shift", + " continue", + " fi", + ' if [ "$1" = "--trust" ]; then', + ' seen_trust="1"', + " shift", + " continue", + " fi", + ' if [ "$1" = "--mode" ]; then', + " shift", + ' mode="$1"', + " shift", + " continue", + " fi", + " shift", + "done", + 'stdin_content="$(cat)"', + ...(input.requireModel !== undefined + ? [ + `if [ "$model" != "${input.requireModel}" ]; then`, + ' printf "%s\\n" "unexpected model: $model" >&2', + " exit 11", + "fi", + ] + : []), + ...(input.requireTrust + ? [ + 'if [ "$seen_trust" != "1" ]; then', + ' printf "%s\\n" "missing --trust" >&2', + " exit 12", + "fi", + ] + : []), + ...(input.requireMode !== undefined + ? [ + `if [ "$mode" != "${input.requireMode}" ]; then`, + ' printf "%s\\n" "unexpected mode: $mode" >&2', + " exit 13", + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 14", + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), + "cat <<'__T3CODE_FAKE_AGENT_OUTPUT__'", + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: input.result, + }), + "__T3CODE_FAKE_AGENT_OUTPUT__", + `exit ${input.exitCode ?? 0}`, + "", + ].join("\n"), ); + yield* fs.chmod(agentPath, 0o755); + return agentPath; }); } -function waitForFileContent(path: string): Effect.Effect { - return Effect.promise(async () => { - const deadline = Date.now() + 5_000; - for (;;) { - try { - return readFileSync(path, "utf8"); - } catch (error) { - if (Date.now() >= deadline) { - throw error instanceof Error ? error : new Error(String(error)); - } - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - }); +function withFakeAgentEnv( + input: { + result: string; + requireModel?: string; + requireTrust?: boolean; + requireMode?: string; + stdinMustContain?: string; + stderr?: string; + exitCode?: number; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-cursor-text-" }); + const agentPath = yield* makeFakeAgentBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + cursor: { + binaryPath: agentPath, + }, + }, + }); + return { serverSettings, previousBinaryPath: previousSettings.providers.cursor.binaryPath }; + }), + () => effect, + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), + ); } it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { - it.effect("uses ACP model config options instead of raw CLI model ids", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); - - return withFakeAcpAgent( + it.effect("uses agent CLI model ids instead of ACP bracket notation for commit messages", () => + withFakeAgentEnv( { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + result: JSON.stringify({ subject: "Add generated commit message", - body: "- verify cursor acp model config path", + body: "- verify agent model mapping", }), + requireModel: "composer-2-fast", + requireTrust: true, + requireMode: "ask", + stdinMustContain: "Staged patch:", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -136,84 +183,25 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", modelSelection: { provider: "cursor", - model: "gpt-5.4", - options: { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }, + model: "composer-2", + options: { fastMode: true }, }, }); expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify cursor acp model config path"); - - const requests = readFileSync(requestLogPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: Record }); - - expect( - requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, - ).toMatchObject({ - _meta: { - parameterizedModelPicker: true, - }, - }); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "model" && - request.params?.value === "gpt-5.4", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "reasoning" && - request.params?.value === "extra-high", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "context" && - request.params?.value === "1m", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "fast" && - request.params?.value === "true", - ), - ).toBe(true); - expect( - requests.find((request) => request.method === "session/prompt")?.params?.prompt, - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Staged patch:"), - }), - ]), - ); - - rmSync(requestLogDir, { recursive: true, force: true }); + expect(generated.body).toBe("- verify agent model mapping"); }), - ); - }); + ), + ); - it.effect("accepts json objects with extra assistant text around them", () => - withFakeAcpAgent( + it.effect("accepts json objects with extra text around them from agent output", () => + withFakeAgentEnv( { - T3_ACP_PROMPT_RESPONSE_TEXT: + result: 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + requireModel: "composer-2", + requireTrust: true, + requireMode: "ask", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -234,65 +222,4 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { }), ), ); - - it.effect("generates thread titles through Cursor ACP text generation", () => - withFakeAcpAgent( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: '"Trim reconnect spinner status after resume."', - }), - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); - - expect(generated.title).toBe("Trim reconnect spinner status after resume."); - }), - ), - ); - - it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); - const exitLogPath = path.join(exitLogDir, "exit.log"); - - return withFakeAcpAgent( - { - T3_ACP_EXIT_LOG_PATH: exitLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Close runtime after generation", - body: "", - }), - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); - - expect(generated.subject).toBe("Close runtime after generation"); - - const exitLog = yield* waitForFileContent(exitLogPath); - expect(exitLog).toContain("exit:0"); - - rmSync(exitLogDir, { recursive: true, force: true }); - }), - ); - }); }); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 8f5c166d817b..7ffdd1e6049b 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -9,6 +9,7 @@ * * @module RoutingTextGeneration */ +import type { ModelSelection } from "@t3tools/contracts"; import { Effect, Layer, Context } from "effect"; import { @@ -59,13 +60,18 @@ const makeRoutingTextGeneration = Effect.gen(function* () { : provider === "cursor" ? cursor : codex; + const routeModelSelection = (provider: ModelSelection["provider"]) => + route(provider === "acp" ? undefined : provider); return { generateCommitMessage: (input) => - route(input.modelSelection.provider).generateCommitMessage(input), - generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), - generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), + routeModelSelection(input.modelSelection.provider).generateCommitMessage(input), + generatePrContent: (input) => + routeModelSelection(input.modelSelection.provider).generatePrContent(input), + generateBranchName: (input) => + routeModelSelection(input.modelSelection.provider).generateBranchName(input), + generateThreadTitle: (input) => + routeModelSelection(input.modelSelection.provider).generateThreadTitle(input), } satisfies TextGenerationShape; }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dfdfab926f85..07d5c2a8057b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -13,7 +13,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { Deferred, Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; @@ -357,199 +357,57 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("generates a thread title on the first turn", async () => { + it("records session lastError and clears active turn when provider turn start fails", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - const seededTitle = "Please investigate reconnect failures after restar..."; - harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/set_config_option", + detail: 'Invalid value for session config option "model"', + }), + ) as never, ); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title"), + commandId: CommandId.make("cmd-turn-start-session-error"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-title"), + messageId: asMessageId("user-message-session-error"), role: "user", - text: "Please investigate reconnect failures after restarting the session.", + text: "hello", attachments: [], }, - titleSeed: seededTitle, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ - message: "Please investigate reconnect failures after restarting the session.", - }); - - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Generated title" - ); - }); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Generated title"); - }); - - it("does not overwrite an existing custom thread title on the first turn", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - const seededTitle = "Please investigate reconnect failures after restar..."; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-custom"), - threadId: ThreadId.make("thread-1"), - title: "Keep this custom title", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-preserve"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-preserve"), - role: "user", - text: "Please investigate reconnect failures after restarting the session.", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - expect(harness.generateThreadTitle).not.toHaveBeenCalled(); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Keep this custom title"); - }); - - it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - const seededTitle = "Fix reconnect spinner on resume"; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ - title: "Reconnect spinner resume bug", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-formatted-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-formatted"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-formatted"), - role: "user", - text: "[effort:high]\\n\\nFix reconnect spinner on resume", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Reconnect spinner resume bug" + thread?.session?.lastError === + 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"' && + thread.session.status === "ready" && + thread.session.activeTurnId === null ); }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Reconnect spinner resume bug"); - }); - - it("generates a worktree branch name for the first turn", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-branch"), - threadId: ThreadId.make("thread-1"), - branch: "t3code/1234abcd", - worktreePath: "/tmp/provider-project-worktree", - }), - ); - - harness.generateBranchName.mockImplementation((input: unknown) => - Effect.succeed({ - branch: - typeof input === "object" && - input !== null && - "modelSelection" in input && - typeof input.modelSelection === "object" && - input.modelSelection !== null && - "model" in input.modelSelection && - typeof input.modelSelection.model === "string" - ? `feature/${input.modelSelection.model}` - : "feature/generated", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-branch-model"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-branch-model"), - role: "user", - text: "Add a safer reconnect backoff.", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateBranchName.mock.calls.length === 1); - await waitFor(() => harness.refreshStatus.mock.calls.length === 1); - expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ - message: "Add a safer reconnect backoff.", + expect(thread?.session).toMatchObject({ + status: "ready", + activeTurnId: null, + lastError: + 'Provider adapter request failed (cursor) for session/set_config_option: Invalid value for session config option "model"', }); - expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); it("forwards codex model options through session start and turn send", async () => { @@ -853,6 +711,57 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves the active session model when in-session model switching is unsupported", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -970,6 +879,63 @@ describe("ProviderCommandReactor", () => { }); }); + it("switches cursor model in-session without restarting", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "cursor", model: "composer-2" }, + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-cursor-model-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-1"), + role: "user", + text: "first cursor turn", + attachments: [], + }, + modelSelection: { provider: "cursor", model: "composer-2" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-cursor-model-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-2"), + role: "user", + text: "second cursor turn", + attachments: [], + }, + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1165,23 +1131,30 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { - const harness = await createHarness(); + it("marks the thread session errored when sendTurn fails after the session is running", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "acp", model: "default", agentServerId: "mistral-vibe" }, + }); const now = new Date().toISOString(); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-1"), + commandId: CommandId.make("cmd-turn-start-send-failure-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-1"), + messageId: asMessageId("user-message-send-failure-1"), role: "user", text: "first", attachments: [], }, + modelSelection: { + provider: "acp", + model: "default", + agentServerId: "mistral-vibe", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); @@ -1189,23 +1162,35 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + harness.sendTurn.mockImplementationOnce( + (_: unknown) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "acp", + method: "session/prompt", + detail: "Invalid API key. Please check your API key and try again.", + }), + ) as unknown as Effect.Effect<{ threadId: ThreadId; turnId: TurnId }, never, never>, + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-2"), + commandId: CommandId.make("cmd-turn-start-send-failure-2"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-2"), + messageId: asMessageId("user-message-send-failure-2"), role: "user", text: "second", attachments: [], }, modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "acp", + model: "default", + agentServerId: "mistral-vibe", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); @@ -1213,26 +1198,24 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); + return thread?.session?.status === "error"; }); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(thread?.session).toMatchObject({ + threadId: "thread-1", + status: "error", + providerName: "acp", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: expect.stringContaining("Invalid API key"), + }); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), ).toMatchObject({ payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + detail: expect.stringContaining("Invalid API key"), }, }); }); @@ -1275,37 +1258,80 @@ describe("ProviderCommandReactor", () => { }); }); - it("starts a fresh session when only projected session state exists", async () => { + it("preserves provider method context when turn start fails", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/set_config_option", + detail: "Invalid cursor/set_config_option payload: Expected string, got null", + }), + ) as never, + ); + await Effect.runPromise( harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-stale"), + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-error"), threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: now, + message: { + messageId: asMessageId("user-message-provider-error"), + role: "user", + text: "hello", + attachments: [], }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", createdAt: now, }), ); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining( + "Provider adapter request failed (cursor) for session/set_config_option", + ), + }, + }); + }); + + it("keeps the full rendered cause for non-adapter turn start failures", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.sendTurn.mockImplementation( + () => + Effect.fail( + new Error("Invalid params", { cause: new Error("session/prompt failed") }), + ) as never, + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-stale"), + commandId: CommandId.make("cmd-turn-start-raw-error"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-stale"), + messageId: asMessageId("user-message-raw-error"), role: "user", - text: "resume codex", + text: "hello", attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -1314,19 +1340,23 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 1); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); - expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5-codex", + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("session/prompt failed"), }, - runtimeMode: "approval-required", - }); - expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), }); }); @@ -1371,6 +1401,60 @@ describe("ProviderCommandReactor", () => { }); }); + it("processes approval responses while a provider turn is still in flight", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const unblockSendTurn = Effect.runSync(Deferred.make()); + + harness.sendTurn.mockImplementation(() => + Deferred.await(unblockSendTurn).pipe( + Effect.as({ + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-1"), + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-blocked-approval"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-blocked-approval"), + role: "user", + text: "need approval while turn is running", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-respond-during-send-turn"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-while-send-turn-blocked"), + decision: "acceptForSession", + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToRequest.mock.calls.length === 1); + expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + requestId: "approval-request-while-send-turn-blocked", + decision: "acceptForSession", + }); + + await Effect.runPromise(Deferred.succeed(unblockSendTurn, undefined)); + }); + it("reacts to thread.user-input.respond by forwarding structured user input answers", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/provider/Layers/AcpAgentRegistry.test.ts b/apps/server/src/provider/Layers/AcpAgentRegistry.test.ts new file mode 100644 index 000000000000..62d7e93b18fe --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAgentRegistry.test.ts @@ -0,0 +1,65 @@ +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Stream } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpAgentRegistryLive } from "./AcpAgentRegistry.ts"; +import { AcpAgentRegistry } from "../Services/AcpAgentRegistry.ts"; + +const settingsLayer = Layer.mock(ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + acp: { + ...DEFAULT_SERVER_SETTINGS.providers.acp, + agentServers: [ + { + id: "node-agent", + name: "Node Agent", + enabled: true, + source: "manual", + distributionType: "manual", + launch: { + command: process.execPath, + args: [], + }, + }, + { + id: "missing-agent", + name: "Missing Agent", + enabled: true, + source: "manual", + distributionType: "manual", + launch: { + command: "t3-code-missing-acp-agent", + args: [], + }, + }, + ], + }, + }, + }), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + streamChanges: Stream.empty, +}); + +const testLayer = AcpAgentRegistryLive.pipe(Layer.provide(settingsLayer)); + +it.effect("reports installed and missing ACP agent commands", () => + Effect.gen(function* () { + const registry = yield* AcpAgentRegistry; + const statuses = yield* registry.listStatuses; + + const installed = statuses.find((status) => status.agentServerId === "node-agent"); + const missing = statuses.find((status) => status.agentServerId === "missing-agent"); + + assert.equal(installed?.status, "ready"); + assert.equal(installed?.installed, true); + assert.equal(missing?.status, "error"); + assert.equal(missing?.installed, false); + assert.match(missing?.message ?? "", /not found/); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/provider/Layers/AcpAgentRegistry.ts b/apps/server/src/provider/Layers/AcpAgentRegistry.ts new file mode 100644 index 000000000000..710af8ed1ce8 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAgentRegistry.ts @@ -0,0 +1,80 @@ +import nodeFs from "node:fs"; +import nodePath from "node:path"; + +import { type AcpAgentServer, type ServerAcpAgentStatus } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpAgentRegistry, type AcpAgentRegistryShape } from "../Services/AcpAgentRegistry.ts"; + +function commandExists(command: string): boolean { + if (nodePath.isAbsolute(command)) { + try { + nodeFs.accessSync(command, nodeFs.constants.X_OK); + return true; + } catch { + return false; + } + } + + const pathValue = process.env.PATH ?? ""; + const pathEntries = pathValue.split(nodePath.delimiter).filter(Boolean); + const executableNames = + process.platform === "win32" + ? [command, `${command}.exe`, `${command}.cmd`, `${command}.bat`] + : [command]; + + return pathEntries.some((entry) => + executableNames.some((name) => { + try { + nodeFs.accessSync(nodePath.join(entry, name), nodeFs.constants.X_OK); + return true; + } catch { + return false; + } + }), + ); +} + +function toStatus(agent: AcpAgentServer, checkedAt: string): ServerAcpAgentStatus { + const installed = commandExists(agent.launch.command); + return { + agentServerId: agent.id, + displayName: agent.name, + enabled: agent.enabled, + installed, + status: agent.enabled ? (installed ? "ready" : "error") : "disabled", + authStatus: "unknown", + checkedAt, + version: agent.importedVersion ?? null, + ...(installed + ? {} + : { + message: + agent.distributionType === "binaryUnsupported" + ? "Binary ACP agents are discoverable but require manual setup in this version." + : `Command '${agent.launch.command}' was not found.`, + }), + }; +} + +const makeAcpAgentRegistry = Effect.gen(function* () { + const settings = yield* ServerSettingsService; + + const getAgentServers: AcpAgentRegistryShape["getAgentServers"] = settings.getSettings.pipe( + Effect.map((serverSettings) => serverSettings.providers.acp.agentServers), + ); + + const listStatuses: AcpAgentRegistryShape["listStatuses"] = Effect.gen(function* () { + const agents = yield* getAgentServers; + const checkedAt = new Date().toISOString(); + return agents.map((agent) => toStatus(agent, checkedAt)); + }); + + return { + getAgentServers, + listStatuses, + } satisfies AcpAgentRegistryShape; +}); + +export const AcpAgentRegistryLive = Layer.effect(AcpAgentRegistry, makeAcpAgentRegistry); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 7c8a4c27a6e2..c7f48e447d25 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,5 +1,6 @@ import type { ClaudeSettings, + ClaudeModelOptions, ClaudeModelSelection, ModelCapabilities, ServerProvider, @@ -33,6 +34,7 @@ import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -173,6 +175,25 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s return modelSelection.model; } } + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getClaudeModelCapabilities(model); + const effort = resolveEffort(caps, modelOptions?.effort); + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; readonly auth: Pick; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 3f83419a3897..de8696074df5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -19,6 +19,8 @@ import { ProviderRegistry, type ProviderRegistryShape } from "../Services/Provid import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { hydrateCachedProvider, + isProviderCacheId, + type ProviderCacheId, PROVIDER_CACHE_IDS, orderProviderSnapshots, readProviderStatusCache, @@ -27,7 +29,7 @@ import { } from "../providerStatusCache.ts"; type ProviderSnapshotSource = { - readonly provider: ProviderKind; + readonly provider: ProviderCacheId; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; @@ -174,15 +176,17 @@ const ProviderRegistryLiveBase = Layer.effect( const providersRef = yield* Ref.make>(cachedProviders); const persistProvider = (provider: ServerProvider) => - writeProviderStatusCache({ - filePath: cachePathByProvider.get(provider.provider)!, - provider, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.tapError(Effect.logError), - Effect.ignore, - ); + isProviderCacheId(provider.provider) + ? writeProviderStatusCache({ + filePath: cachePathByProvider.get(provider.provider)!, + provider, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.tapError(Effect.logError), + Effect.ignore, + ) + : Effect.void; const upsertProviders = Effect.fn("upsertProviders")(function* ( nextProviders: ReadonlyArray, diff --git a/apps/server/src/provider/Services/AcpAgentRegistry.ts b/apps/server/src/provider/Services/AcpAgentRegistry.ts new file mode 100644 index 000000000000..8d03fc542feb --- /dev/null +++ b/apps/server/src/provider/Services/AcpAgentRegistry.ts @@ -0,0 +1,12 @@ +import type { AcpAgentServer, ServerAcpAgentStatus, ServerSettingsError } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface AcpAgentRegistryShape { + readonly listStatuses: Effect.Effect, ServerSettingsError>; + readonly getAgentServers: Effect.Effect, ServerSettingsError>; +} + +export class AcpAgentRegistry extends Context.Service()( + "t3/provider/Services/AcpAgentRegistry", +) {} diff --git a/apps/server/src/provider/Services/AcpRegistryClient.ts b/apps/server/src/provider/Services/AcpRegistryClient.ts new file mode 100644 index 000000000000..7b72ae7f4798 --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryClient.ts @@ -0,0 +1,19 @@ +import type { AcpRegistryListResult } from "@t3tools/contracts"; +import { Context, Schema } from "effect"; +import type { Effect } from "effect"; + +export class AcpRegistryClientError extends Schema.TaggedErrorClass()( + "AcpRegistryClientError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export interface AcpRegistryClientShape { + readonly listAgents: Effect.Effect; +} + +export class AcpRegistryClient extends Context.Service()( + "t3/provider/Services/AcpRegistryClient", +) {} diff --git a/apps/server/src/provider/acp/AcpRegistryClient.ts b/apps/server/src/provider/acp/AcpRegistryClient.ts new file mode 100644 index 000000000000..e575d134a313 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryClient.ts @@ -0,0 +1,104 @@ +import { + AcpRegistryIndex, + type AcpRegistryAgent, + type AcpRegistryListResult, +} from "@t3tools/contracts"; +import { Effect, Layer, Schema } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + AcpRegistryClient, + AcpRegistryClientError, + type AcpRegistryClientShape, +} from "../Services/AcpRegistryClient.ts"; + +function toLaunchSpec(agent: AcpRegistryAgent) { + if (agent.distribution.npx) { + return { + supported: true as const, + distributionType: "npx" as const, + launch: { + command: "npx", + args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], + }, + }; + } + if (agent.distribution.uvx) { + return { + supported: true as const, + distributionType: "uvx" as const, + launch: { + command: "uvx", + args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], + }, + }; + } + return { + supported: false as const, + distributionType: "binaryUnsupported" as const, + launch: null, + }; +} + +const makeAcpRegistryClient = Effect.gen(function* () { + const settings = yield* ServerSettingsService; + + const listAgents: AcpRegistryClientShape["listAgents"] = settings.getSettings.pipe( + Effect.mapError( + (cause) => + new AcpRegistryClientError({ + detail: cause.message, + cause, + }), + ), + Effect.flatMap((serverSettings) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(serverSettings.providers.acp.registryUrl); + if (!response.ok) { + throw new Error(`Registry request failed with status ${response.status}`); + } + return response.json(); + }, + catch: (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : "Failed to fetch ACP registry", + ...(cause !== undefined ? { cause } : {}), + }), + }), + ), + Effect.flatMap((raw) => + Schema.decodeUnknownEffect(AcpRegistryIndex)(raw).pipe( + Effect.mapError( + (cause) => + new AcpRegistryClientError({ + detail: "Registry response did not match the ACP registry schema.", + cause, + }), + ), + ), + ), + Effect.map( + (registry): AcpRegistryListResult => ({ + registryVersion: registry.version, + agents: registry.agents + .map((agent) => { + const resolved = toLaunchSpec(agent); + return { + agent, + supported: resolved.supported, + distributionType: resolved.distributionType, + launch: resolved.launch, + }; + }) + .toSorted((left, right) => left.agent.name.localeCompare(right.agent.name)), + }), + ), + ); + + return { + listAgents, + } satisfies AcpRegistryClientShape; +}); + +export const AcpRegistryClientLive = Layer.effect(AcpRegistryClient, makeAcpRegistryClient); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 369fca6218c7..53f9110ea74b 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -8,16 +8,27 @@ export const PROVIDER_CACHE_IDS = [ "opencode", "cursor", ] as const satisfies ReadonlyArray; +export type ProviderCacheId = (typeof PROVIDER_CACHE_IDS)[number]; +const PROVIDER_CACHE_ID_SET = new Set(PROVIDER_CACHE_IDS); const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); const providerOrderRank = (provider: ServerProvider["provider"]): number => { - const rank = PROVIDER_CACHE_IDS.indexOf(provider); + if (!PROVIDER_CACHE_ID_SET.has(provider)) { + return Number.MAX_SAFE_INTEGER; + } + const rank = PROVIDER_CACHE_IDS.indexOf(provider as ProviderCacheId); return rank === -1 ? Number.MAX_SAFE_INTEGER : rank; }; +export function isProviderCacheId( + provider: ServerProvider["provider"], +): provider is ProviderCacheId { + return PROVIDER_CACHE_ID_SET.has(provider); +} + const mergeProviderModels = ( fallbackModels: ReadonlyArray, cachedModels: ReadonlyArray, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3036e..2b0e6c744663 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -79,6 +79,14 @@ import { ProviderRegistry, type ProviderRegistryShape, } from "./provider/Services/ProviderRegistry.ts"; +import { + AcpAgentRegistry, + type AcpAgentRegistryShape, +} from "./provider/Services/AcpAgentRegistry.ts"; +import { + AcpRegistryClient, + type AcpRegistryClientShape, +} from "./provider/Services/AcpRegistryClient.ts"; import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; @@ -320,6 +328,8 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + acpAgentRegistry?: Partial; + acpRegistryClient?: Partial; serverSettings?: Partial; open?: Partial; gitCore?: Partial; @@ -424,6 +434,22 @@ const buildAppUnderTest = (options?: { ...options?.layers?.providerRegistry, }), ), + Layer.provide( + Layer.mock(AcpAgentRegistry)({ + getAgentServers: Effect.succeed([]), + listStatuses: Effect.succeed([]), + ...options?.layers?.acpAgentRegistry, + }), + ), + Layer.provide( + Layer.mock(AcpRegistryClient)({ + listAgents: Effect.succeed({ + registryVersion: "test", + agents: [], + }), + ...options?.layers?.acpRegistryClient, + }), + ), Layer.provide( Layer.mock(ServerSettingsService)({ start: Effect.void, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f94bbb34b5bf..d81e1eaf0edf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,6 +43,8 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import { AcpAgentRegistryLive } from "./provider/Layers/AcpAgentRegistry.ts"; +import { AcpRegistryClientLive } from "./provider/acp/AcpRegistryClient.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; @@ -198,6 +200,10 @@ const GitLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const AcpRegistryLayerLive = Layer.mergeAll(AcpAgentRegistryLive, AcpRegistryClientLive).pipe( + Layer.provide(ServerSettingsLive), +); + const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(GitCoreLive), @@ -234,6 +240,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(AcpRegistryLayerLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 655ede9441f6..bf981d5acc01 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -158,16 +158,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); const next = yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }, + textGenerationModelSelection: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, }); - assert.deepEqual(next.textGenerationModelSelection, { - provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }); + assert.deepEqual( + next.textGenerationModelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c47c442a86f6..dba567ede1d6 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "cursor"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aac716cfeb63..b18d38092482 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,8 @@ import { observeRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { AcpAgentRegistry } from "./provider/Services/AcpAgentRegistry.ts"; +import { AcpRegistryClient } from "./provider/Services/AcpRegistryClient.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { ServerSettingsService } from "./serverSettings.ts"; @@ -141,6 +143,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; + const acpAgentRegistry = yield* AcpAgentRegistry; + const acpRegistryClient = yield* AcpRegistryClient; const config = yield* ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; @@ -512,6 +516,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; + const acpAgentServers = yield* acpAgentRegistry.listStatuses; const settings = yield* serverSettings.getSettings; const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); @@ -524,6 +529,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, + acpAgentServers, availableEditors: resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, @@ -772,6 +778,24 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { "rpc.aggregate": "server", }), + [WS_METHODS.serverListAcpRegistry]: (_input) => + observeRpcEffect( + WS_METHODS.serverListAcpRegistry, + acpRegistryClient.listAgents.pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list ACP registry agents", { + error: error.message, + }), + ), + Effect.orElseSucceed(() => ({ + registryVersion: "unavailable", + agents: [], + })), + ), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6857a51b5196..b9eb90a357a5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -178,6 +178,7 @@ function createBaseServerConfig(): ServerConfig { skills: [], }, ], + acpAgentServers: [], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 535c0d9fcaed..05226c3eed68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,7 +112,11 @@ import { import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; +import { + getModelSelectionOptions, + resolveAppModelSelection, + resolveBuiltInSelectableProvider, +} from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { @@ -1942,8 +1946,8 @@ export default function ChatView(props: ChatViewProps) { input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.provider !== serverThread.modelSelection.provider || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) + JSON.stringify(getModelSelectionOptions(input.modelSelection) ?? null) !== + JSON.stringify(getModelSelectionOptions(serverThread.modelSelection) ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -2547,7 +2551,7 @@ export default function ChatView(props: ChatViewProps) { ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ctxSelectedModelSelection.options, + getModelSelectionOptions(ctxSelectedModelSelection), ); // Auto-title from first message @@ -3112,17 +3116,28 @@ export default function ChatView(props: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); + if (provider === "acp") { + const nextModelSelection: ModelSelection = { + provider: "acp", + agentServerId: model, + model: "default", + }; + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + ); + setStickyComposerModelSelection(nextModelSelection); + scheduleComposerFocus(); + return; + } + const resolvedProvider = resolveBuiltInSelectableProvider(providerStatuses, provider); const resolvedModel = resolveAppModelSelection( resolvedProvider, settings, providerStatuses, model, ); - const nextModelSelection: ModelSelection = { - provider: resolvedProvider, - model: resolvedModel, - }; + const nextModelSelection = createModelSelection(resolvedProvider, resolvedModel); setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7eadd4e41be6..a2038a087579 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -84,6 +84,7 @@ function createBaseServerConfig(): ServerConfig { skills: [], }, ], + acpAgentServers: [], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", @@ -107,6 +108,7 @@ function createBaseServerConfig(): ServerConfig { serverPassword: "", customModels: [], }, + acp: { enabled: true, registryUrl: "", agentServers: [] }, }, }, }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3d3b081af997..6b5f7d20d698 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -572,6 +572,9 @@ export const ChatComposer = memo( selectedProviderByThreadId ?? threadProvider ?? "codex", ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + const acpAgents = settings.providers.acp.enabled + ? settings.providers.acp.agentServers.filter((agent) => agent.enabled) + : []; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -621,6 +624,7 @@ export const ChatComposer = memo( opencode: providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], + acp: [], }), [providerStatuses], ); @@ -1887,6 +1891,7 @@ export const ChatComposer = memo( providers={providerStatuses} keybindings={keybindings} modelOptionsByProvider={modelOptionsByProvider} + acpAgents={acpAgents} terminalOpen={terminalOpen} open={isComposerModelPickerOpen} {...(composerProviderState.modelPickerIconClassName diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 7619a635545f..3fcca9d8a18c 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -23,6 +23,10 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str const threadKey = scopedThreadKey(threadRef); const provider = props?.modelSelection?.provider ?? "claudeAgent"; const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + const modelSelectionOptions = + props?.modelSelection && "options" in props.modelSelection + ? props.modelSelection.options + : undefined; useComposerDraftStore.setState({ draftsByThreadKey: { @@ -36,7 +40,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str [provider]: { provider, model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + ...(modelSelectionOptions ? { options: modelSelectionOptions } : {}), }, }, activeProvider: provider, @@ -50,7 +54,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; + const providerOptions = modelSelectionOptions; const models = provider === "claudeAgent" ? [ diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 82720425ef55..51657e2166c6 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,4 +1,5 @@ import { + type AcpAgentServer, type ProviderKind, PROVIDER_DISPLAY_NAMES, type ResolvedKeybindingsConfig, @@ -28,6 +29,7 @@ type ModelPickerItem = { name: string; shortName?: string; subProvider?: string; + iconUrl?: string; provider: ProviderKind; }; @@ -40,6 +42,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { providers?: ReadonlyArray; keybindings?: ResolvedKeybindingsConfig; modelOptionsByProvider: Record>; + acpAgents?: ReadonlyArray>; terminalOpen: boolean; onRequestClose?: () => void; onProviderModelChange: (provider: ProviderKind, model: string) => void; @@ -113,19 +116,38 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // Flatten models into a searchable array const flatModels = useMemo(() => { - return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { - if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { - return []; - } - return models.map((m) => ({ - slug: m.slug, - name: m.name, - ...(m.shortName ? { shortName: m.shortName } : {}), - ...(m.subProvider ? { subProvider: m.subProvider } : {}), - provider: providerKind as ProviderKind, - })) satisfies Array; - }); - }, [props.modelOptionsByProvider, readyProviderSet]); + const providerModels = Object.entries(props.modelOptionsByProvider).flatMap( + ([providerKind, models]) => { + if (providerKind === "acp") { + return []; + } + if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { + return []; + } + return models.map((m) => ({ + slug: m.slug, + name: m.name, + ...(m.shortName ? { shortName: m.shortName } : {}), + ...(m.subProvider ? { subProvider: m.subProvider } : {}), + provider: providerKind as ProviderKind, + })) satisfies Array; + }, + ); + const acpModels = (props.acpAgents ?? []) + .filter((agent) => agent.enabled) + .map((agent) => { + const item: ModelPickerItem = { + slug: agent.id, + name: agent.name, + provider: "acp", + }; + if (agent.iconUrl) { + item.iconUrl = agent.iconUrl; + } + return item; + }) satisfies Array; + return [...providerModels, ...acpModels]; + }, [props.acpAgents, props.modelOptionsByProvider, readyProviderSet]); // Filter models based on search query and selected provider const filteredModels = useMemo(() => { @@ -223,6 +245,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const handleModelSelect = useCallback( (modelSlug: string, provider: ProviderKind) => { + if (provider === "acp") { + onProviderModelChange(provider, modelSlug); + return; + } const resolvedModel = resolveSelectableModel( provider, modelSlug, @@ -411,6 +437,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { selectedProvider={selectedProvider} onSelectProvider={handleSelectProvider} {...(props.providers && { providers: props.providers })} + acpAgents={props.acpAgents ?? []} /> )} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index b4f23cacdd55..88b6a1349382 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,7 +1,7 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type AcpAgentServer, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; -import { Gemini, GithubCopilotIcon } from "../Icons"; +import { Gemini, GithubCopilotIcon, OpenCodeIcon } from "../Icons"; import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; @@ -42,6 +42,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { selectedProvider: ProviderKind | "favorites"; onSelectProvider: (provider: ProviderKind | "favorites") => void; providers?: ReadonlyArray; + acpAgents?: ReadonlyArray>; }) { const handleProviderClick = (provider: ProviderKind | "favorites") => { props.onSelectProvider(provider); @@ -153,6 +154,37 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ); })} + {(props.acpAgents?.some((agent) => agent.enabled) ?? false) ? ( +
+ {props.selectedProvider === "acp" &&
} + + handleProviderClick("acp")} + type="button" + aria-label="ACP Agents" + > + + + } + /> + + ACP Agents + + +
+ ) : null} + {/* Gemini button (coming soon) */} { codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], cursor: [], opencode: [], + acp: [], } as const; const screen = await render( ; keybindings?: ResolvedKeybindingsConfig; modelOptionsByProvider: Record>; + acpAgents?: ReadonlyArray>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; @@ -33,6 +35,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + disabledReason?: string; onOpenChange?: (open: boolean) => void; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { @@ -40,15 +43,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeProvider = props.lockedProvider ?? props.provider; const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - // If the current slug belongs to a different provider (for example after a provider - // switch or disable), prefer the active provider's first option so the trigger icon - // and label stay in sync instead of showing a stale foreign slug. + const selectedAcpAgent = props.acpAgents?.find((agent) => agent.id === props.model); const selectedModel = - selectedProviderOptions.find((option) => option.slug === props.model) ?? - selectedProviderOptions[0]; + activeProvider === "acp" + ? selectedAcpAgent + ? { + slug: selectedAcpAgent.id, + name: selectedAcpAgent.name, + } + : { + slug: props.model, + name: props.model === "default" ? "ACP Agent" : props.model, + } + : (selectedProviderOptions.find((option) => option.slug === props.model) ?? + selectedProviderOptions[0]); const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; - const triggerSubtitle = selectedModel?.subProvider; + const triggerSubtitle = + selectedModel && "subProvider" in selectedModel ? selectedModel.subProvider : undefined; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; const setIsMenuOpen = (open: boolean) => { @@ -94,6 +106,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { props.triggerClassName, )} disabled={props.disabled} + title={props.disabled ? props.disabledReason : undefined} /> } > @@ -148,6 +161,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {...(props.providers && { providers: props.providers })} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByProvider={props.modelOptionsByProvider} + acpAgents={props.acpAgents ?? []} terminalOpen={props.terminalOpen ?? false} onRequestClose={() => setIsMenuOpen(false)} onProviderModelChange={handleProviderModelChange} diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index af03c99a5e7e..6cd4ae7fcd89 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -163,6 +163,9 @@ const composerProviderRegistry: Record = { opencode: createProviderRegistryEntry("opencode", { showInteractionModeToggle: false, }), + acp: createProviderRegistryEntry("acp", { + showInteractionModeToggle: false, + }), }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 66f5220771c2..0c3e97e8c379 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -7,10 +7,11 @@ export const PROVIDER_ICON_BY_PROVIDER: Record = { claudeAgent: ClaudeAI, opencode: OpenCodeIcon, cursor: CursorIcon, + acp: OpenCodeIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; + value: Exclude; label: string; available: true; pickerSidebarBadge?: "new" | "soon"; diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index b508b29b77e8..05b09c6ff25e 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -189,6 +189,7 @@ function createBaseServerConfig(): ServerConfig { keybindings: [], issues: [], providers: [], + acpAgentServers: [], availableEditors: ["cursor"], observability: { logsDirectoryPath: "/repo/project/.t3/logs", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 230b0a9965d7..dc55ce27ac74 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -43,8 +43,10 @@ import { useDesktopUpdateState, } from "../../lib/desktopUpdateReactQuery"; import { + type BuiltInProviderKind, MAX_CUSTOM_MODEL_LENGTH, getCustomModelOptionsByProvider, + getModelSelectionOptions, resolveAppModelSelectionState, } from "../../modelSelection"; import { ensureLocalApi, readLocalApi } from "../../localApi"; @@ -102,7 +104,7 @@ const TIMESTAMP_FORMAT_LABELS = { } as const; type InstallProviderSettings = { - provider: ProviderKind; + provider: BuiltInProviderKind; title: string; badgeLabel?: string; binaryPlaceholder: string; @@ -527,7 +529,9 @@ export function GeneralSettingsPanel() { const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< Partial> >({}); - const [openProviderDetails, setOpenProviderDetails] = useState>({ + const [openProviderDetails, setOpenProviderDetails] = useState< + Record + >({ codex: Boolean( settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || @@ -555,7 +559,7 @@ export function GeneralSettingsPanel() { ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record + Record >({ codex: "", claudeAgent: "", @@ -563,7 +567,7 @@ export function GeneralSettingsPanel() { opencode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> + Partial> >({}); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const refreshingRef = useRef(false); @@ -609,7 +613,7 @@ export function GeneralSettingsPanel() { const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; - const textGenModelOptions = textGenerationModelSelection.options; + const textGenModelOptions = getModelSelectionOptions(textGenerationModelSelection); const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, serverProviders, @@ -666,7 +670,7 @@ export function GeneralSettingsPanel() { const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; const addCustomModel = useCallback( - (provider: ProviderKind) => { + (provider: BuiltInProviderKind) => { const customModelInput = customModelInputByProvider[provider]; const customModels = settings.providers[provider].customModels; const normalized = normalizeModelSlug(customModelInput, provider); @@ -736,7 +740,7 @@ export function GeneralSettingsPanel() { ); const removeCustomModel = useCallback( - (provider: ProviderKind, slug: string) => { + (provider: BuiltInProviderKind, slug: string) => { updateSettings({ providers: { ...settings.providers, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d789d7d510a4..8d3d8d8a30fe 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -93,7 +93,7 @@ function resetComposerDraftStore() { function modelSelection( provider: "codex" | "claudeAgent" | "cursor", model: string, - options?: ModelSelection["options"], + options?: ProviderModelOptions["codex" | "claudeAgent" | "cursor"], ): ModelSelection { return { provider, @@ -102,6 +102,10 @@ function modelSelection( } as ModelSelection; } +function modelOptions(selection: ModelSelection | null | undefined) { + return selection && "options" in selection ? selection.options : undefined; +} + function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { return options; } @@ -1097,8 +1101,10 @@ describe("composerDraftStore modelSelection", () => { store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelOptions(draft?.modelSelectionByProvider.codex)).toEqual({ + reasoningEffort: "xhigh", + }); + expect(modelOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ effort: "max" }); }); it("preserves other provider options when switching the active model selection", () => { @@ -1118,7 +1124,7 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(modelOptions(draft?.modelSelectionByProvider.codex)).toEqual({ fastMode: true }); expect(draft?.activeProvider).toBe("claudeAgent"); }); @@ -1284,7 +1290,7 @@ describe("composerDraftStore provider-scoped option updates", () => { expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ effort: "max" }); expect(draft?.activeProvider).toBe("codex"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 36304fc02365..1dbada4e724a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -390,7 +390,10 @@ interface ComposerDraftModelState { function providerModelOptionsFromSelection( modelSelection: ModelSelection | null | undefined, ): ProviderModelOptions | null { - if (!modelSelection?.options) { + if (!modelSelection || modelSelection.provider === "acp" || !("options" in modelSelection)) { + return null; + } + if (!modelSelection.options) { return null; } @@ -405,7 +408,10 @@ function modelSelectionByProviderToOptions( if (!map) return null; const result: Record = {}; for (const [provider, selection] of Object.entries(map)) { - if (selection?.options) { + if (provider === "acp") { + continue; + } + if (selection && selection.provider !== "acp" && selection.options) { result[provider] = selection.options; } } @@ -531,7 +537,11 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode" + return value === "codex" || + value === "claudeAgent" || + value === "cursor" || + value === "opencode" || + value === "acp" ? value : null; } @@ -695,6 +705,16 @@ function normalizeModelSelection( if (provider === null) { return null; } + if (provider === "acp") { + const agentServerId = candidate?.agentServerId; + return typeof agentServerId === "string" && agentServerId.trim().length > 0 + ? { + provider: "acp", + agentServerId: agentServerId.trim(), + model: "default", + } + : null; + } const rawModel = candidate?.model ?? legacy?.model; if (typeof rawModel !== "string") { return null; @@ -721,6 +741,9 @@ function legacySyncModelSelectionOptions( if (modelSelection === null) { return null; } + if (modelSelection.provider === "acp") { + return modelSelection; + } const options = modelOptions?.[modelSelection.provider]; return createModelSelection(modelSelection.provider, modelSelection.model, options); } @@ -729,7 +752,12 @@ function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, currentModelOptions: ProviderModelOptions | null | undefined, ): ProviderModelOptions | null { - if (modelSelection?.options === undefined) { + if ( + modelSelection === null || + modelSelection.provider === "acp" || + !("options" in modelSelection) || + modelSelection.options === undefined + ) { return normalizeProviderModelOptions(currentModelOptions); } return legacyReplaceProviderModelOptions( @@ -797,6 +825,23 @@ export function deriveEffectiveComposerModelState(input: { projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { + if (input.selectedProvider === "acp") { + const activeSelection = input.draft?.modelSelectionByProvider?.acp; + const selectedAgentServerId = + (activeSelection?.provider === "acp" ? activeSelection.agentServerId : undefined) ?? + (input.threadModelSelection?.provider === "acp" + ? input.threadModelSelection.agentServerId + : undefined) ?? + (input.projectModelSelection?.provider === "acp" + ? input.projectModelSelection.agentServerId + : undefined) ?? + input.settings.providers.acp.agentServers.find((agent) => agent.enabled)?.id ?? + "default"; + return { + selectedModel: selectedAgentServerId, + modelOptions: null, + }; + } const baseModel = normalizeModelSlug( input.threadModelSelection?.model ?? input.projectModelSelection?.model, @@ -2266,7 +2311,9 @@ const composerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; if (normalized) { const current = nextMap[normalized.provider]; - if (normalized.options !== undefined) { + if (normalized.provider === "acp") { + nextMap[normalized.provider] = normalized; + } else if (normalized.options !== undefined) { // Explicit options provided → use them nextMap[normalized.provider] = normalized; } else { @@ -2274,7 +2321,7 @@ const composerDraftStore = create()( nextMap[normalized.provider] = createModelSelection( normalized.provider, normalized.model, - current?.options, + current?.provider !== "acp" ? current?.options : undefined, ); } } @@ -2323,7 +2370,7 @@ const composerDraftStore = create()( current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], opts, ); - } else if (current?.options) { + } else if (current && "options" in current && current.options) { // Remove options but keep the selection const { options: _, ...rest } = current; nextMap[provider] = rest as ModelSelection; @@ -2377,7 +2424,7 @@ const composerDraftStore = create()( currentForProvider?.model ?? fallbackModel, providerOpts, ); - } else if (currentForProvider?.options) { + } else if (currentForProvider?.provider !== "acp" && currentForProvider?.options) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; } @@ -2397,7 +2444,7 @@ const composerDraftStore = create()( stickyBase.model, providerOpts, ); - } else if (stickyBase.options) { + } else if (stickyBase.provider !== "acp" && stickyBase.options) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index f53122175399..5aec00479e21 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -246,6 +246,7 @@ const baseServerConfig: ServerConfig = { keybindings: [], issues: [], providers: defaultProviders, + acpAgentServers: [], availableEditors: ["cursor"], observability: { logsDirectoryPath: "/tmp/workspace/.config/logs", diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 4401b5b778e3..993ef663439d 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -115,6 +115,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, updateSettings: rpcClient.server.updateSettings, + listAcpRegistry: rpcClient.server.listAcpRegistry, }, }; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2ffa8e20140c..da17e4bfb935 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -2,6 +2,7 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, type ModelSelection, type ProviderKind, + type ProviderModelOptions, type ServerProvider, } from "@t3tools/contracts"; import { @@ -20,6 +21,13 @@ import { ModelEsque } from "./components/chat/providerIconUtils"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export type BuiltInProviderKind = Exclude; +export const BUILT_IN_PROVIDER_KINDS = [ + "codex", + "claudeAgent", + "opencode", + "cursor", +] as const satisfies readonly BuiltInProviderKind[]; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -37,6 +45,27 @@ export interface AppModelOption { isCustom: boolean; } +export function getModelSelectionOptions( + selection: ModelSelection | null | undefined, +): ProviderModelOptions[ProviderKind] | undefined { + return selection?.provider === "acp" ? undefined : selection?.options; +} + +export function isBuiltInProviderKind(provider: ProviderKind): provider is BuiltInProviderKind { + return provider !== "acp"; +} + +export function resolveBuiltInSelectableProvider( + providers: ReadonlyArray, + provider: ProviderKind | null | undefined, +): BuiltInProviderKind { + const resolved = resolveSelectableProvider( + providers.filter((candidate) => candidate.provider !== "acp"), + provider === "acp" ? "codex" : provider, + ); + return resolved === "acp" ? "codex" : resolved; +} + const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", @@ -62,9 +91,16 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record ({ - slug, - name, - ...(shortName ? { shortName } : {}), - ...(subProvider ? { subProvider } : {}), - isCustom, - }), + ({ slug, name, shortName, subProvider, isCustom }) => { + const option: AppModelOption = { + slug, + name, + isCustom, + }; + if (shortName) { + option.shortName = shortName; + } + if (subProvider) { + option.subProvider = subProvider; + } + return option; + }, ); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); @@ -200,6 +246,7 @@ export function getCustomModelOptionsByProvider( "opencode", selectedProvider === "opencode" ? selectedModel : undefined, ), + acp: [], }; } @@ -211,7 +258,7 @@ export function resolveAppModelSelectionState( provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = resolveSelectableProvider(providers, selection.provider); + const provider = resolveBuiltInSelectableProvider(providers, selection.provider); // When the provider changed due to fallback (e.g. selected provider was disabled), // don't carry over the old provider's model — use the fallback provider's default. @@ -223,7 +270,7 @@ export function resolveAppModelSelectionState( models: getProviderModels(providers, provider), prompt: "", modelOptions: { - [provider]: provider === selection.provider ? selection.options : undefined, + [provider]: provider === selection.provider ? getModelSelectionOptions(selection) : undefined, }, }); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index e901a895f49a..030e24b5fbcc 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,7 +1,10 @@ import { DEFAULT_MODEL_BY_PROVIDER, + type ClaudeModelOptions, + type CodexModelOptions, type CursorModelOptions, type ModelCapabilities, + type OpenCodeModelOptions, type ProviderKind, type ServerProvider, type ServerProviderModel, @@ -39,6 +42,9 @@ export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderKind, ): boolean { + if (provider === "acp") { + return true; + } if (providers.length === 0) { return true; } @@ -61,6 +67,9 @@ export function getProviderModelCapabilities( model: string | null | undefined, provider: ProviderKind, ): ModelCapabilities { + if (provider === "acp") { + return EMPTY_CAPABILITIES; + } const slug = normalizeModelSlug(model, provider); return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; } @@ -77,29 +86,104 @@ export function getDefaultServerModel( ); } +export function normalizeCodexModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = caps.reasoningEffortLevels.find( + (option) => option.isDefault, + )?.value; + const reasoningEffort = + trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort ?? null; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function normalizeCursorModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CursorModelOptions | null | undefined, ): CursorModelOptions | undefined { + const defaultEffort = caps.reasoningEffortLevels.find((option) => option.isDefault)?.value; const reasoning = trimOrNull(modelOptions?.reasoning); const reasoningValue = - reasoning && hasEffortLevel(caps, reasoning) + reasoning && hasEffortLevel(caps, reasoning) && reasoning !== defaultEffort ? (reasoning as CursorModelOptions["reasoning"]) : undefined; - const fastMode = - caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" - ? modelOptions.fastMode + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: CursorModelOptions = { + ...(reasoningValue ? { reasoning: reasoningValue } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(thinking === false ? { thinking: false } : {}), + ...(contextWindow ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const defaultReasoningEffort = caps.reasoningEffortLevels.find( + (option) => option.isDefault, + )?.value; + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort : undefined; const thinking = caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" ? modelOptions.thinking : undefined; + const fastMode = + caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" + ? modelOptions.fastMode + : undefined; const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: CursorModelOptions = { - ...(reasoningValue ? { reasoning: reasoningValue } : {}), - ...(fastMode !== undefined ? { fastMode } : {}), + const nextOptions: ClaudeModelOptions = { ...(thinking !== undefined ? { thinking } : {}), + ...(effort ? { effort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } + +function resolveLabeledOption( + options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, + raw: string | null | undefined, +): string | undefined { + if (!options || options.length === 0) { + return raw ?? undefined; + } + if (raw && options.some((option) => option.value === raw)) { + return raw; + } + return options.find((option) => option.isDefault)?.value ?? options[0]?.value; +} + +export function normalizeOpenCodeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: OpenCodeModelOptions | null | undefined, +): OpenCodeModelOptions | undefined { + const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); + const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); + const nextOptions: OpenCodeModelOptions = { + ...(variant ? { variant } : {}), + ...(agent ? { agent } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index a587fcd9f1e3..a20f3f5a1dee 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -79,6 +79,7 @@ const baseServerConfig: ServerConfig = { keybindings: [], issues: [], providers: defaultProviders, + acpAgentServers: [], availableEditors: ["cursor"], observability: { logsDirectoryPath: "/tmp/workspace/.config/logs", diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b67be32dc78e..c962fa3827c0 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -108,6 +108,7 @@ export interface WsRpcClient { readonly updateSettings: ( patch: ServerSettingsPatch, ) => ReturnType>; + readonly listAcpRegistry: RpcUnaryNoArgMethod; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; @@ -213,6 +214,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), updateSettings: (patch) => transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), + listAcpRegistry: () => + transport.request((client) => client[WS_METHODS.serverListAcpRegistry]({})), subscribeConfig: (listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribeServerConfig]({}), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 44e0498d7b9a..1ad29a5cc3b1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -22,7 +22,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind; +export type ProviderPickerKind = Exclude; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3b1976bf9aba..848dd2b830a4 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -12,17 +12,21 @@ import type { OrchestrationSession, OrchestrationSessionStatus, OrchestrationThread, - OrchestrationThreadShell, OrchestrationThreadActivity, + OrchestrationThreadShell, ProjectId, ScopedProjectRef, ScopedThreadRef, + ThreadId, + TurnId, } from "@t3tools/contracts"; import { ProviderKind } from "@t3tools/contracts"; -import type { ThreadId, TurnId } from "@t3tools/contracts"; -import { Schema } from "effect"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; +import { Schema } from "effect"; +import { resolveEnvironmentHttpUrl } from "./environments/runtime"; +import { sanitizeThreadErrorMessage } from "./rpc/transportError"; +import { getThreadFromEnvironmentState } from "./threadDerivation"; import { type ChatMessage, type Project, @@ -34,9 +38,6 @@ import { type ThreadTurnState, type TurnDiffSummary, } from "./types"; -import { resolveEnvironmentHttpUrl } from "./environments/runtime"; -import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; export interface EnvironmentState { projectIds: ProjectId[]; diff --git a/bun.lock b/bun.lock index e9b7511e3406..9b63a5ae10d8 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@t3tools/web": "workspace:*", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/ws": "^8.5.13", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", "tsdown": "catalog:", diff --git a/packages/contracts/src/acp.ts b/packages/contracts/src/acp.ts new file mode 100644 index 000000000000..9a4aafef7a41 --- /dev/null +++ b/packages/contracts/src/acp.ts @@ -0,0 +1,106 @@ +import { Effect, Schema } from "effect"; +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const AcpAgentServerId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), +); +export type AcpAgentServerId = typeof AcpAgentServerId.Type; + +export const AcpDistributionType = Schema.Literals(["manual", "npx", "uvx", "binaryUnsupported"]); +export type AcpDistributionType = typeof AcpDistributionType.Type; + +export const AcpAgentSource = Schema.Literals(["manual", "registry"]); +export type AcpAgentSource = typeof AcpAgentSource.Type; + +export const AcpLaunchSpec = Schema.Struct({ + command: TrimmedNonEmptyString, + args: Schema.Array(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type AcpLaunchSpec = typeof AcpLaunchSpec.Type; + +export const AcpAgentServer = Schema.Struct({ + id: AcpAgentServerId, + name: TrimmedNonEmptyString, + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + source: AcpAgentSource, + distributionType: AcpDistributionType, + launch: AcpLaunchSpec, + description: Schema.optional(TrimmedNonEmptyString), + website: Schema.optional(TrimmedNonEmptyString), + repository: Schema.optional(TrimmedNonEmptyString), + iconUrl: Schema.optional(TrimmedNonEmptyString), + registryAgentId: Schema.optional(TrimmedNonEmptyString), + importedVersion: Schema.optional(TrimmedNonEmptyString), +}); +export type AcpAgentServer = typeof AcpAgentServer.Type; + +export const ServerAcpAgentStatus = Schema.Struct({ + agentServerId: AcpAgentServerId, + enabled: Schema.Boolean, + installed: Schema.Boolean, + status: Schema.Literals(["ready", "warning", "error", "disabled"]), + authStatus: Schema.Literals(["authenticated", "unauthenticated", "unknown"]), + checkedAt: IsoDateTime, + displayName: TrimmedNonEmptyString, + message: Schema.optional(TrimmedNonEmptyString), + version: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ServerAcpAgentStatus = typeof ServerAcpAgentStatus.Type; + +const AcpRegistryBinaryDistribution = Schema.Record( + Schema.String, + Schema.Struct({ + archive: TrimmedNonEmptyString, + cmd: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + }), +); + +export const AcpRegistryAgent = Schema.Struct({ + id: AcpAgentServerId, + name: TrimmedNonEmptyString, + version: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, + repository: Schema.optional(TrimmedNonEmptyString), + website: Schema.optional(TrimmedNonEmptyString), + authors: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + license: Schema.optional(TrimmedNonEmptyString), + icon: Schema.optional(TrimmedNonEmptyString), + distribution: Schema.Struct({ + binary: Schema.optional(AcpRegistryBinaryDistribution), + npx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + }), + ), + uvx: Schema.optional( + Schema.Struct({ + package: TrimmedNonEmptyString, + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + }), + ), + }), +}); +export type AcpRegistryAgent = typeof AcpRegistryAgent.Type; + +export const AcpRegistryIndex = Schema.Struct({ + version: TrimmedNonEmptyString, + agents: Schema.Array(AcpRegistryAgent), +}); +export type AcpRegistryIndex = typeof AcpRegistryIndex.Type; + +export const ResolvedRegistryAcpAgent = Schema.Struct({ + agent: AcpRegistryAgent, + supported: Schema.Boolean, + distributionType: AcpDistributionType, + launch: Schema.NullOr(AcpLaunchSpec), +}); +export type ResolvedRegistryAcpAgent = typeof ResolvedRegistryAcpAgent.Type; + +export const AcpRegistryListResult = Schema.Struct({ + registryVersion: TrimmedNonEmptyString, + agents: Schema.Array(ResolvedRegistryAcpAgent), +}); +export type AcpRegistryListResult = typeof AcpRegistryListResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 47081d8df1be..ac9db5360834 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas.ts"; +export * from "./acp.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./ipc.ts"; @@ -6,6 +7,7 @@ export * from "./terminal.ts"; export * from "./provider.ts"; export * from "./providerRuntime.ts"; export * from "./model.ts"; +export * from "./ws.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a1abc0fa4a00..4727949846dd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -30,6 +30,7 @@ import type { ServerProviderUpdatedPayload, ServerUpsertKeybindingResult, } from "./server.ts"; +import type { AcpRegistryListResult } from "./acp.ts"; import type { TerminalClearInput, TerminalCloseInput, @@ -219,6 +220,7 @@ export interface LocalApi { upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; + listAcpRegistry: () => Promise; }; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5bb82caf4218..d963326f12ae 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -46,6 +46,7 @@ export const CursorModelOptions = Schema.Struct({ contextWindow: Schema.optional(Schema.String), }); export type CursorModelOptions = typeof CursorModelOptions.Type; + export const OpenCodeModelOptions = Schema.Struct({ variant: Schema.optional(TrimmedNonEmptyString), agent: Schema.optional(TrimmedNonEmptyString), @@ -57,6 +58,7 @@ export const ProviderModelOptions = Schema.Struct({ claudeAgent: Schema.optional(ClaudeModelOptions), cursor: Schema.optional(CursorModelOptions), opencode: Schema.optional(OpenCodeModelOptions), + acp: Schema.optional(Schema.Struct({})), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -90,6 +92,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Record = { claudeAgent: "claude-sonnet-4-6", cursor: "auto", opencode: "openai/gpt-5", + acp: "default", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -100,6 +103,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record> = { @@ -139,6 +143,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { claudeAgent: "Claude", cursor: "Cursor", opencode: "OpenCode", + acp: "ACP Agent", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 087a66709011..a16bdb5e24f4 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,11 +1,11 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; +import { AcpAgentServerId } from "./acp.ts"; import { ClaudeModelOptions, CodexModelOptions, CursorModelOptions, OpenCodeModelOptions, } from "./model.ts"; -import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, @@ -20,9 +20,11 @@ import { TrimmedNonEmptyString, TurnId, } from "./baseSchemas.ts"; +import { RepositoryIdentity } from "./environment.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", + getSnapshot: "orchestration.getSnapshot", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", @@ -30,7 +32,11 @@ export const ORCHESTRATION_WS_METHODS = { subscribeThread: "orchestration.subscribeThread", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]); +export const ORCHESTRATION_WS_CHANNELS = { + domainEvent: "orchestration.domainEvent", +} as const; + +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode", "acp"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -68,6 +74,7 @@ export const CursorModelSelection = Schema.Struct({ options: Schema.optionalKey(CursorModelOptions), }); export type CursorModelSelection = typeof CursorModelSelection.Type; + export const OpenCodeModelSelection = Schema.Struct({ provider: Schema.Literal("opencode"), model: TrimmedNonEmptyString, @@ -75,11 +82,19 @@ export const OpenCodeModelSelection = Schema.Struct({ }); export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; +export const AcpModelSelection = Schema.Struct({ + provider: Schema.Literal("acp"), + agentServerId: AcpAgentServerId, + model: TrimmedNonEmptyString, +}); +export type AcpModelSelection = typeof AcpModelSelection.Type; + export const ModelSelection = Schema.Union([ CodexModelSelection, ClaudeModelSelection, CursorModelSelection, OpenCodeModelSelection, + AcpModelSelection, ]); export type ModelSelection = typeof ModelSelection.Type; @@ -1170,6 +1185,9 @@ export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInp const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; +export const OrchestrationGetSnapshotInput = Schema.Struct({}); +export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; + export const OrchestrationRpcSchemas = { dispatchCommand: { input: ClientOrchestrationCommand, diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index f91d0c89e0a5..0e1c0eb793f9 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -24,10 +24,10 @@ describe("ProviderSessionStartInput", () => { }); expect(parsed.runtimeMode).toBe("full-access"); expect(parsed.modelSelection?.provider).toBe("codex"); - expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } + expect(parsed.modelSelection.model).toBe("gpt-5.3-codex"); expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); expect(parsed.modelSelection.options?.fastMode).toBe(true); }); @@ -59,10 +59,10 @@ describe("ProviderSessionStartInput", () => { }); expect(parsed.provider).toBe("claudeAgent"); expect(parsed.modelSelection?.provider).toBe("claudeAgent"); - expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); if (parsed.modelSelection?.provider !== "claudeAgent") { throw new Error("Expected claude modelSelection"); } + expect(parsed.modelSelection.model).toBe("claude-sonnet-4-6"); expect(parsed.modelSelection.options?.thinking).toBe(true); expect(parsed.modelSelection.options?.effort).toBe("max"); expect(parsed.modelSelection.options?.fastMode).toBe(true); @@ -83,8 +83,8 @@ describe("ProviderSessionStartInput", () => { }); expect(parsed.provider).toBe("cursor"); expect(parsed.modelSelection?.provider).toBe("cursor"); - expect(parsed.modelSelection?.model).toBe("composer-2"); if (parsed.modelSelection?.provider === "cursor") { + expect(parsed.modelSelection.model).toBe("composer-2"); expect(parsed.modelSelection.options?.fastMode).toBe(true); } }); @@ -105,10 +105,10 @@ describe("ProviderSendTurnInput", () => { }); expect(parsed.modelSelection?.provider).toBe("codex"); - expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); if (parsed.modelSelection?.provider !== "codex") { throw new Error("Expected codex modelSelection"); } + expect(parsed.modelSelection.model).toBe("gpt-5.3-codex"); expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); expect(parsed.modelSelection.options?.fastMode).toBe(true); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e27e3aa7ef96..8c6b83d346fb 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { AcpAgentServerId } from "./acp.ts"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, @@ -33,6 +34,7 @@ const ProviderSessionStatus = Schema.Literals([ export const ProviderSession = Schema.Struct({ provider: ProviderKind, + agentServerId: Schema.optional(AcpAgentServerId), status: ProviderSessionStatus, runtimeMode: RuntimeMode, cwd: Schema.optional(TrimmedNonEmptyString), @@ -109,6 +111,7 @@ export const ProviderEvent = Schema.Struct({ id: EventId, kind: ProviderEventKind, provider: ProviderKind, + agentServerId: Schema.optional(AcpAgentServerId), threadId: ThreadId, createdAt: IsoDateTime, method: TrimmedNonEmptyString, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 8387880ea350..1b38f543876b 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; +import { AcpAgentServerId } from "./acp.ts"; import { EventId, IsoDateTime, @@ -23,8 +24,8 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), - Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("codex.sdk.thread-event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); @@ -246,6 +247,7 @@ const RuntimeErrorType = Schema.Literal("runtime.error"); const ProviderRuntimeEventBase = Schema.Struct({ eventId: EventId, provider: ProviderKind, + agentServerId: Schema.optional(AcpAgentServerId), threadId: ThreadId, createdAt: IsoDateTime, turnId: Schema.optional(TurnId), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5dec716a7257..417492b159e1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor.ts"; import { AuthAccessStreamEvent } from "./auth.ts"; +import { AcpRegistryListResult } from "./acp.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -93,6 +94,7 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", + gitStatus: "git.status", gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", @@ -118,6 +120,7 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverListAcpRegistry: "server.listAcpRegistry", // Streaming subscriptions subscribeGitStatus: "subscribeGitStatus", @@ -156,6 +159,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: ServerSettingsError, }); +export const WsServerListAcpRegistryRpc = Rpc.make(WS_METHODS.serverListAcpRegistry, { + payload: Schema.Struct({}), + success: AcpRegistryListResult, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -361,6 +369,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerListAcpRegistryRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3cd25f2e8e9b..3b9d053b9e3c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect"; -import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ServerAcpAgentStatus } from "./acp.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, @@ -8,8 +8,9 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; import { EditorId } from "./editor.ts"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderKind } from "./orchestration.ts"; import { ServerSettings } from "./settings.ts"; @@ -121,6 +122,7 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviders, + acpAgentServers: Schema.Array(ServerAcpAgentStatus), availableEditors: Schema.Array(EditorId), observability: ServerObservability, settings: ServerSettings, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cad1d197c12f..1fc651f76624 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AcpAgentServer } from "./acp.ts"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ClaudeModelOptions, @@ -104,6 +105,7 @@ export const CursorSettings = Schema.Struct({ customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type CursorSettings = typeof CursorSettings.Type; + export const OpenCodeSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), binaryPath: makeBinaryPathSetting("opencode"), @@ -113,6 +115,17 @@ export const OpenCodeSettings = Schema.Struct({ }); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AcpSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + registryUrl: TrimmedString.pipe( + Schema.withDecodingDefault( + Effect.succeed("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"), + ), + ), + agentServers: Schema.Array(AcpAgentServer).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type AcpSettings = typeof AcpSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -140,6 +153,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + acp: AcpSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); @@ -215,6 +229,11 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(OpenCodeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("acp")), + agentServerId: Schema.optionalKey(TrimmedNonEmptyString), + model: Schema.optionalKey(TrimmedNonEmptyString), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -246,6 +265,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AcpSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + registryUrl: Schema.optionalKey(Schema.String), + agentServers: Schema.optionalKey(Schema.Array(AcpAgentServer)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -264,6 +289,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + acp: Schema.optionalKey(AcpSettingsPatch), }), ), }); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts new file mode 100644 index 000000000000..298c399b3db3 --- /dev/null +++ b/packages/contracts/src/ws.ts @@ -0,0 +1,219 @@ +import { Schema, Struct } from "effect"; +import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +import { + ClientOrchestrationCommand, + OrchestrationEvent, + ORCHESTRATION_WS_CHANNELS, + OrchestrationGetFullThreadDiffInput, + ORCHESTRATION_WS_METHODS, + OrchestrationGetSnapshotInput, + OrchestrationGetTurnDiffInput, + OrchestrationReplayEventsInput, +} from "./orchestration.ts"; +import { + GitActionProgressEvent, + GitCheckoutInput, + GitCreateBranchInput, + GitPreparePullRequestThreadInput, + GitCreateWorktreeInput, + GitInitInput, + GitListBranchesInput, + GitPullInput, + GitPullRequestRefInput, + GitRemoveWorktreeInput, + GitRunStackedActionInput, + GitStatusInput, +} from "./git.ts"; +import { + TerminalClearInput, + TerminalCloseInput, + TerminalEvent, + TerminalOpenInput, + TerminalResizeInput, + TerminalRestartInput, + TerminalWriteInput, +} from "./terminal.ts"; +import { KeybindingRule } from "./keybindings.ts"; +import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project.ts"; +import { OpenInEditorInput } from "./editor.ts"; +import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server.ts"; +import { ServerSettingsPatch } from "./settings.ts"; +import { WS_METHODS } from "./rpc.ts"; + +// ── WebSocket RPC Method Names ─────────────────────────────────────── + +// ── Push Event Channels ────────────────────────────────────────────── + +export const WS_CHANNELS = { + gitActionProgress: "git.actionProgress", + terminalEvent: "terminal.event", + serverWelcome: "server.welcome", + serverConfigUpdated: "server.configUpdated", + serverProvidersUpdated: "server.providersUpdated", +} as const; + +// -- Tagged Union of all request body schemas ───────────────────────── + +const tagRequestBody = ( + tag: Tag, + schema: Schema.Struct, +) => + schema.mapFields( + Struct.assign({ _tag: Schema.tag(tag) }), + // PreserveChecks is safe here. No existing schema should have checks depending on the tag + { unsafePreserveChecks: true }, + ); + +const WebSocketRequestBody = Schema.Union([ + // Orchestration methods + tagRequestBody( + ORCHESTRATION_WS_METHODS.dispatchCommand, + Schema.Struct({ command: ClientOrchestrationCommand }), + ), + tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput), + tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput), + tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput), + tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput), + + // Project Search + tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), + tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), + + // Shell methods + tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), + + // Git methods + tagRequestBody(WS_METHODS.gitPull, GitPullInput), + tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), + tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), + tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), + tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), + tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), + tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), + tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), + tagRequestBody(WS_METHODS.gitInit, GitInitInput), + tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), + tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), + + // Terminal methods + tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), + tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), + tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput), + tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput), + tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), + tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + + // Server meta + tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), + tagRequestBody(WS_METHODS.serverListAcpRegistry, Schema.Struct({})), +]); + +export const WebSocketRequest = Schema.Struct({ + id: TrimmedNonEmptyString, + body: WebSocketRequestBody, +}); +export type WebSocketRequest = typeof WebSocketRequest.Type; + +export const WebSocketResponse = Schema.Struct({ + id: TrimmedNonEmptyString, + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + message: Schema.String, + }), + ), +}); +export type WebSocketResponse = typeof WebSocketResponse.Type; + +export const WsPushSequence = NonNegativeInt; +export type WsPushSequence = typeof WsPushSequence.Type; + +export const WsWelcomePayload = Schema.Struct({ + cwd: TrimmedNonEmptyString, + projectName: TrimmedNonEmptyString, + bootstrapProjectId: Schema.optional(ProjectId), + bootstrapThreadId: Schema.optional(ThreadId), +}); +export type WsWelcomePayload = typeof WsWelcomePayload.Type; + +export interface WsPushPayloadByChannel { + readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; + readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; + readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; + readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; + readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; + readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; +} + +export type WsPushChannel = keyof WsPushPayloadByChannel; +export type WsPushData = WsPushPayloadByChannel[C]; + +const makeWsPushSchema = >( + channel: Channel, + payload: Payload, +) => + Schema.Struct({ + type: Schema.Literal("push"), + sequence: WsPushSequence, + channel: Schema.Literal(channel), + data: payload, + }); + +export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload); +export const WsPushServerConfigUpdated = makeWsPushSchema( + WS_CHANNELS.serverConfigUpdated, + ServerConfigUpdatedPayload, +); +export const WsPushServerProvidersUpdated = makeWsPushSchema( + WS_CHANNELS.serverProvidersUpdated, + ServerProviderUpdatedPayload, +); +export const WsPushGitActionProgress = makeWsPushSchema( + WS_CHANNELS.gitActionProgress, + GitActionProgressEvent, +); +export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); +export const WsPushOrchestrationDomainEvent = makeWsPushSchema( + ORCHESTRATION_WS_CHANNELS.domainEvent, + OrchestrationEvent, +); + +export const WsPushChannelSchema = Schema.Literals([ + WS_CHANNELS.gitActionProgress, + WS_CHANNELS.serverWelcome, + WS_CHANNELS.serverConfigUpdated, + WS_CHANNELS.serverProvidersUpdated, + WS_CHANNELS.terminalEvent, + ORCHESTRATION_WS_CHANNELS.domainEvent, +]); +export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; + +export const WsPush = Schema.Union([ + WsPushServerWelcome, + WsPushServerConfigUpdated, + WsPushServerProvidersUpdated, + WsPushGitActionProgress, + WsPushTerminalEvent, + WsPushOrchestrationDomainEvent, +]); +export type WsPush = typeof WsPush.Type; + +export type WsPushMessage = Extract; + +export const WsPushEnvelopeBase = Schema.Struct({ + type: Schema.Literal("push"), + sequence: WsPushSequence, + channel: WsPushChannelSchema, + data: Schema.Unknown, +}); +export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type; + +// ── Union of all server → client messages ───────────────────────────── + +export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); +export type WsResponse = typeof WsResponse.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 426ceca865ef..9a081c934989 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -8,8 +8,6 @@ import { hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, normalizeModelSlug, resolveContextWindow, resolveEffort, @@ -194,57 +192,3 @@ describe("resolveContextWindow", () => { expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); }); }); - -describe("normalize*ModelOptionsWithCapabilities", () => { - it("preserves explicit false codex fast mode", () => { - expect( - normalizeCodexModelOptionsWithCapabilities(codexCaps, { - reasoningEffort: "high", - fastMode: false, - }), - ).toEqual({ - reasoningEffort: "high", - fastMode: false, - }); - }); - - it("preserves the default Claude context window explicitly", () => { - expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), - ).toEqual({ - effort: "high", - contextWindow: "200k", - }); - }); - - it("omits unsupported Claude context window options", () => { - expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), - ).toEqual({ - thinking: true, - }); - }); -}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ad62debf68cf..f84b5dd9e3ef 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -5,9 +5,9 @@ import { type ClaudeModelOptions, type CodexModelOptions, type CursorModelOptions, - type ModelCapabilities, type ModelSelection, type OpenCodeModelOptions, + type ModelCapabilities, type ProviderKind, type ProviderModelOptions, } from "@t3tools/contracts"; @@ -167,6 +167,8 @@ export function normalizeProviderModelOptionsWithCapabilities( caps, modelOptions as OpenCodeModelOptions, ); + case "acp": + return undefined; } } @@ -279,6 +281,12 @@ export function createModelSelection( model, ...(options ? { options: options as OpenCodeModelOptions } : {}), }; + case "acp": + return { + provider, + agentServerId: model, + model: "default", + }; } } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index cb9d93735734..97cc69abd589 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -56,6 +56,10 @@ function shouldReplaceTextGenerationModelSelection( const withModelSelectionOptions = (options: Options | undefined) => options ? { options } : {}; +const getModelSelectionPatchOptions = ( + patch: NonNullable, +) => ("options" in patch ? patch.options : undefined); + /** * Applies a server settings patch while treating textGenerationModelSelection as * replace-on-provider/model updates. This prevents stale nested options from @@ -74,6 +78,22 @@ export function applyServerSettingsPatch( const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; const model = selectionPatch.model ?? current.textGenerationModelSelection.model; + if (provider === "acp") { + const agentServerId = + ("agentServerId" in selectionPatch ? selectionPatch.agentServerId : undefined) ?? + (current.textGenerationModelSelection.provider === "acp" + ? current.textGenerationModelSelection.agentServerId + : model); + return { + ...next, + textGenerationModelSelection: { + provider, + agentServerId, + model, + }, + }; + } + return { ...next, textGenerationModelSelection: @@ -81,14 +101,16 @@ export function applyServerSettingsPatch( ? { provider, model, - ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined), + ...withModelSelectionOptions( + getModelSelectionPatchOptions(selectionPatch) as CodexModelOptions | undefined, + ), } : provider === "claudeAgent" ? { provider, model, ...withModelSelectionOptions( - selectionPatch.options as ClaudeModelOptions | undefined, + getModelSelectionPatchOptions(selectionPatch) as ClaudeModelOptions | undefined, ), } : provider === "cursor" @@ -96,14 +118,14 @@ export function applyServerSettingsPatch( provider, model, ...withModelSelectionOptions( - selectionPatch.options as CursorModelOptions | undefined, + getModelSelectionPatchOptions(selectionPatch) as CursorModelOptions | undefined, ), } : { provider, model, ...withModelSelectionOptions( - selectionPatch.options as OpenCodeModelOptions | undefined, + getModelSelectionPatchOptions(selectionPatch) as OpenCodeModelOptions | undefined, ), }, }; diff --git a/patches/effect@4.0.0-beta.41.patch b/patches/effect@4.0.0-beta.41.patch new file mode 100644 index 000000000000..f7296bc10cb5 --- /dev/null +++ b/patches/effect@4.0.0-beta.41.patch @@ -0,0 +1,108 @@ +diff --git a/src/SchemaRepresentation.ts b/src/SchemaRepresentation.ts +index e1a87e2b..a7797678 100644 +--- a/src/SchemaRepresentation.ts ++++ b/src/SchemaRepresentation.ts +@@ -3007,7 +3007,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + } + } + +- let out = on(js) ++ const hasAnyOf = Array.isArray(js.anyOf) ++ const hasOneOf = Array.isArray(js.oneOf) ++ const base = ++ hasAnyOf || hasOneOf ++ ? ({ ...js, anyOf: undefined, oneOf: undefined } as JsonSchema.JsonSchema) ++ : js ++ ++ let out = on(base) + + const annotations = collectAnnotations(js) + if (annotations !== undefined) { +@@ -3018,6 +3025,14 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out) + } + ++ if (hasAnyOf) { ++ out = combine({ _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" }, out) ++ } ++ ++ if (hasOneOf) { ++ out = combine({ _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" }, out) ++ } ++ + return out + } + +@@ -3054,12 +3069,7 @@ export function fromJsonSchemaMultiDocument(document: JsonSchema.MultiDocument<" + } else { + return { _tag: "Union", types, mode: "anyOf" } + } +- } else if (Array.isArray(js.anyOf)) { +- return { _tag: "Union", types: js.anyOf.map((type) => recur(type)), mode: "anyOf" } +- } else if (Array.isArray(js.oneOf)) { +- return { _tag: "Union", types: js.oneOf.map((type) => recur(type)), mode: "oneOf" } + } +- + const type = isType(js.type) ? js.type : getType(js) + if (type !== undefined) { + switch (type) { +diff --git a/dist/SchemaRepresentation.js b/dist/SchemaRepresentation.js +index 0d3f01d7..c3672558 100644 +--- a/dist/SchemaRepresentation.js ++++ b/dist/SchemaRepresentation.js +@@ -2042,7 +2042,14 @@ export function fromJsonSchemaMultiDocument(document, options) { + js = {}; + } + } +- let out = on(js); ++ const hasAnyOf = Array.isArray(js.anyOf); ++ const hasOneOf = Array.isArray(js.oneOf); ++ const base = hasAnyOf || hasOneOf ? { ++ ...js, ++ anyOf: undefined, ++ oneOf: undefined ++ } : js; ++ let out = on(base); + const annotations = collectAnnotations(js); + if (annotations !== undefined) { + out = combine(out, { +@@ -2053,6 +2060,20 @@ export function fromJsonSchemaMultiDocument(document, options) { + if (Array.isArray(js.allOf)) { + return js.allOf.reduce((acc, curr) => combine(acc, recur(curr)), out); + } ++ if (hasAnyOf) { ++ out = combine({ ++ _tag: "Union", ++ types: js.anyOf.map(type => recur(type)), ++ mode: "anyOf" ++ }, out); ++ } ++ if (hasOneOf) { ++ out = combine({ ++ _tag: "Union", ++ types: js.oneOf.map(type => recur(type)), ++ mode: "oneOf" ++ }, out); ++ } + return out; + } + function on(js) { +@@ -2105,18 +2126,6 @@ export function fromJsonSchemaMultiDocument(document, options) { + mode: "anyOf" + }; + } +- } else if (Array.isArray(js.anyOf)) { +- return { +- _tag: "Union", +- types: js.anyOf.map(type => recur(type)), +- mode: "anyOf" +- }; +- } else if (Array.isArray(js.oneOf)) { +- return { +- _tag: "Union", +- types: js.oneOf.map(type => recur(type)), +- mode: "oneOf" +- }; + } + const type = isType(js.type) ? js.type : getType(js); + if (type !== undefined) { diff --git a/patches/effect@4.0.0-beta.42.patch b/patches/effect@4.0.0-beta.42.patch new file mode 100644 index 000000000000..ec5acf2c19d6 --- /dev/null +++ b/patches/effect@4.0.0-beta.42.patch @@ -0,0 +1,96 @@ +diff --git a/dist/unstable/rpc/RpcSerialization.js b/dist/unstable/rpc/RpcSerialization.js +index 26dc4f2608580af60306a99243a9abf2b3581640..7c25eeb940428fc2844c986cbb98b989518e7f5a 100644 +--- a/dist/unstable/rpc/RpcSerialization.js ++++ b/dist/unstable/rpc/RpcSerialization.js +@@ -139,7 +139,7 @@ function decodeJsonRpcRaw(decoded, batches) { + } + function decodeJsonRpcMessage(decoded) { + if ("method" in decoded) { +- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { ++ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { + const tag = decoded.method.slice("@effect/rpc/".length); + const requestId = decoded.params?.requestId; + return requestId ? { +@@ -151,7 +151,7 @@ function decodeJsonRpcMessage(decoded) { + } + return { + _tag: "Request", +- id: decoded.id ? String(decoded.id) : "", ++ id: decoded.id != null ? String(decoded.id) : "", + tag: decoded.method, + payload: decoded.params ?? null, + headers: decoded.headers ?? [], +@@ -241,7 +241,7 @@ function encodeJsonRpcMessage(response) { + jsonrpc: "2.0", + method: response.tag, + params: response.payload, +- id: response.id && Number(response.id), ++ id: response.id !== "" ? Number(response.id) : undefined, + headers: response.headers, + traceId: response.traceId, + spanId: response.spanId, +@@ -271,14 +271,14 @@ function encodeJsonRpcMessage(response) { + if (response.exit._tag === "Success") { + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + result: response.exit.value + }; + } + const error = response.exit.cause.find(failure => failure._tag === "Fail"); + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + error: response.exit._tag === "Failure" ? { + _tag: "Cause", + code: error && Predicate.hasProperty(error, "code") ? Number(error.code) : 0, +diff --git a/src/unstable/rpc/RpcSerialization.ts b/src/unstable/rpc/RpcSerialization.ts +index 319effeabbc94237cd1a11799ac5d9bbd39d0170..66e82004de888b38f19a792886b461daa1789c55 100644 +--- a/src/unstable/rpc/RpcSerialization.ts ++++ b/src/unstable/rpc/RpcSerialization.ts +@@ -180,7 +180,7 @@ function decodeJsonRpcRaw( + + function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded { + if ("method" in decoded) { +- if (!decoded.id && decoded.method.startsWith("@effect/rpc/")) { ++ if (decoded.id == null && decoded.method.startsWith("@effect/rpc/")) { + const tag = decoded.method.slice("@effect/rpc/".length) as + | RpcMessage.FromServerEncoded["_tag"] + | Exclude +@@ -194,7 +194,7 @@ function decodeJsonRpcMessage(decoded: JsonRpcMessage): RpcMessage.FromClientEnc + } + return { + _tag: "Request", +- id: decoded.id ? String(decoded.id) : "", ++ id: decoded.id != null ? String(decoded.id) : "", + tag: decoded.method, + payload: decoded.params ?? null, + headers: decoded.headers ?? [], +@@ -308,7 +308,7 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag + jsonrpc: "2.0", + method: response.tag, + params: response.payload, +- id: response.id && Number(response.id), ++ id: response.id !== "" ? Number(response.id) : undefined, + headers: response.headers, + traceId: response.traceId, + spanId: response.spanId, +@@ -335,14 +335,14 @@ function encodeJsonRpcMessage(response: RpcMessage.FromServerEncoded | RpcMessag + if (response.exit._tag === "Success") { + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + result: response.exit.value + } as any + } + const error = response.exit.cause.find((failure) => failure._tag === "Fail") + return { + jsonrpc: "2.0", +- id: response.requestId ? Number(response.requestId) : undefined, ++ id: response.requestId !== "" ? Number(response.requestId) : undefined, + error: response.exit._tag === "Failure" ? + { + _tag: "Cause",