diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 5ea142d08b4d..4a71d78618a3 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -648,8 +648,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? undefined : currentModelSelection.instanceId; const modelOptions = useMemo( - () => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId), - [props.serverConfig, currentModelSelection, lockedProviderInstanceId], + () => + buildModelOptions( + props.serverConfig, + currentModelSelection, + lockedProviderInstanceId, + props.projectCwd, + ), + [props.serverConfig, currentModelSelection, lockedProviderInstanceId, props.projectCwd], ); const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const currentModelOption = diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 1ffa7696250e..e0c9da6e9473 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -555,14 +555,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); + const modelCwd = + (workspaceMode === "worktree" + ? selectedProject?.workspaceRoot + : (selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null; const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + undefined, + modelCwd, ), [ selectedEnvironmentServerConfig, + modelCwd, draftModelSelection, projectDefaultModelSelection, stickyModelSelection, @@ -1077,11 +1084,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const text = draft.text.trim(); // Use the displayed selection rules without substituting an unavailable // Antigravity model while the task is queued. - const draftModelSelection = + const currentModelSelection = resolveSelectableModelSelection( selectedEnvironmentServerConfig, draft.modelSelection ?? null, ) ?? selectedModel; + const draftModelSelection = resolveNewTaskModelSelection({ + draftSelection: currentModelSelection, + projectDefaultSelection: null, + stickySelection: null, + modelOptions: buildModelOptions( + selectedEnvironmentServerConfig, + currentModelSelection, + undefined, + modelCwd, + ), + }); if (text.length === 0 || !draftModelSelection) { return null; } @@ -1151,6 +1169,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { defaultRuntimeMode, editingPendingProject, editingPendingTask, + modelCwd, selectedEnvironmentServerConfig, selectedModel, selectedProject, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 463ff5cc3662..bbfe2fda6552 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -13,6 +13,101 @@ import { } from "./modelOptions"; describe("mobile model options", () => { + it("preserves a selected project agent only in the matching workspace catalog", () => { + const config = { + providers: [ + { + instanceId: "opencode", + driver: "opencode", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "opencode/model", + name: "Model", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "variant", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High" }, + { id: "low", label: "Low", isDefault: true }, + ], + currentValue: "low", + }, + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "build", label: "Build", isDefault: true }], + currentValue: "build", + }, + ], + }, + }, + ], + workspaceSnapshots: ["readonly", "audit"].map((name) => ({ + cwd: "/work/" + name, + checkedAt: "2026-01-01T00:00:00.000Z", + skills: [], + slashCommands: [], + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: name, label: name }, + ], + }, + ], + })), + }, + ], + } as unknown as ServerConfig; + const selection: ModelSelection = { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode/model", + options: [ + { id: "variant", value: "high" }, + { id: "agent", value: "readonly" }, + ], + }; + const localOptions = buildModelOptions(config, selection, undefined, "/work/readonly"); + const [local] = localOptions; + expect(local?.selection).toEqual(selection); + const otherOptions = buildModelOptions(config, selection, undefined, "/work/audit"); + const [other] = otherOptions; + expect(other?.selection.options).toEqual([ + { id: "variant", value: "high" }, + { id: "agent", value: "build" }, + ]); + const [global] = buildModelOptions(config, selection); + expect(global?.selection.options).toEqual(other?.selection.options); + for (const modelOptions of [localOptions, otherOptions]) { + for (const source of [ + "draftSelection", + "projectDefaultSelection", + "stickySelection", + ] as const) { + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: null, + stickySelection: null, + [source]: selection, + modelOptions, + }), + ).toEqual(modelOptions[0]?.selection); + } + } + }); it("groups models by provider and flags legacy entries", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4469becee1a2..429f8d7b484c 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,3 +1,4 @@ +import { resolveProviderForCwd } from "@t3tools/client-runtime/providerSkills"; import type { MenuAction } from "@react-native-menu/menu"; import type { ModelCapabilities, @@ -145,24 +146,34 @@ export function resolveNewTaskModelSelection(input: { readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; }): ModelSelection | null { - return ( + const selection = input.draftSelection ?? input.projectDefaultSelection ?? input.stickySelection ?? input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? - null + null; + if (!selection) return null; + const option = input.modelOptions.find( + (option) => + option.selection.instanceId === selection.instanceId && + option.selection.model === selection.model, ); + return option && option.providerDriver !== "antigravity" + ? normalizeSelectionOptions(selection, option.capabilities) + : selection; } export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, providerInstanceId?: ModelSelection["instanceId"], + cwd?: string | null, ): ReadonlyArray { const options = new Map(); - for (const provider of config?.providers ?? []) { + for (const snapshot of config?.providers ?? []) { + const provider = resolveProviderForCwd(snapshot, cwd); if ( (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || !provider.enabled || diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6df5f827fa76..dfd221a69f63 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -398,6 +398,59 @@ const history = { }; describe("OpenCode2 adapter", () => { + it.effect("switches to the selected project agent and back to build before prompting", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.switchAgent", { sessionID: SESSION, agent: "readonly" }), + reply("session.switchAgent", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + out("session.switchAgent", { sessionID: SESSION, agent: "build" }), + reply("session.switchAgent", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const first = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn( + turnInput(thread, { ...bigPickle, options: [{ id: "agent", value: "readonly" }] }), + ); + assert.equal((yield* Fiber.join(first))?.status, "completed"); + const second = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(second))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("keeps plan mode authoritative over an explicit project agent", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.switchAgent", { sessionID: SESSION, agent: "plan" }), + reply("session.switchAgent", null), + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { + sessionID: SESSION, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "edit", resource: "*", effect: "deny" }, + ...mcpRules, + ], + }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...turnInput(thread, { ...bigPickle, options: [{ id: "agent", value: "readonly" }] }), + runtimePolicy: { ...policy(), interactionMode: "plan" }, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); it.effect("switches the session's model and variant before a turn that changed them", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index d1e81eb725c4..62de4f887976 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -765,9 +765,11 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.id === right?.id && (left.variant ?? "default") === (right?.variant ?? "default"); -/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ +/** Plan mode takes precedence over the selected workspace agent. */ const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => - input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; + input.runtimePolicy.interactionMode === "plan" + ? "plan" + : (getModelSelectionStringOptionValue(input.modelSelection, "agent") ?? "build"); /** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ const commandOf = (text: string) => { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index 48100991e71c..dc1025568eb4 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -22,6 +22,7 @@ import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; import * as ProviderMaintenance from "../providerMaintenance.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { + OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES, OPENCODE_2_WORKSPACE_RESPONSES, replayOpenCodeServer, @@ -87,31 +88,175 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); - it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => + it.effect( + "lists each OpenCode 2 workspace's own agents, skills and commands from its server", + () => + Effect.gen(function* () { + serverStarts.length = 0; + const requested: Array = []; + const replay = replayOpenCodeServer( + { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, + "secret", + requested, + ); + const directories: string[] = []; + let agentsUnavailable = true; + const server = HttpClient.make((request) => { + if (new URL(request.url).pathname !== "/api/agent") return replay.execute(request); + const directory = request.urlParams.params.find( + ([key]) => key === "location[directory]", + )?.[1]; + assert.isDefined(directory); + directories.push(directory!); + if (directory === "/unavailable" && agentsUnavailable) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + location: { directory }, + data: [ + { id: "build", mode: "primary", hidden: false }, + { id: "plan", mode: "primary", hidden: false }, + { id: directory === "/work" ? "readonly" : "audit", mode: "all", hidden: false }, + { id: "secret", mode: "primary", hidden: true }, + { id: "explore", mode: "subagent", hidden: false }, + ].map((agent) => ({ + ...agent, + name: agent.id, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + })), + }), + ), + ); + }); + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, + server, + ); + + const first = yield* instance.snapshotForCwd!("/work").pipe(Effect.forkChild); + yield* TestClock.adjust("11 seconds"); + const workspace = yield* Fiber.join(first); + assert.includeMembers( + workspace.skills.map((skill) => skill.name), + ["plum", "opencode"], + ); + assert.deepStrictEqual( + workspace.slashCommands.map((command) => command.name), + ["compact", "init", "review", "bee"], + ); + assert.deepStrictEqual(workspace.optionDescriptors?.[0], { + id: "agent", + label: "Agent", + type: "select", + currentValue: "build", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + { id: "readonly", label: "Readonly" }, + ], + }); + const second = yield* instance.snapshotForCwd!("/other").pipe(Effect.forkChild); + yield* TestClock.adjust("11 seconds"); + const other = yield* Fiber.join(second); + const agent = other.optionDescriptors?.[0]; + assert.deepStrictEqual( + agent?.type === "select" ? agent.options.map((option) => option.id) : [], + ["build", "plan", "audit"], + ); + assert.deepStrictEqual( + (yield* instance.snapshotForCwd!("/work")).optionDescriptors, + workspace.optionDescriptors, + ); + const unavailable = yield* instance.snapshotForCwd!("/unavailable").pipe(Effect.forkChild); + yield* TestClock.adjust("11 seconds"); + const failedAgents = yield* Fiber.join(unavailable); + assert.deepStrictEqual(failedAgents.skills, workspace.skills); + assert.deepStrictEqual(failedAgents.slashCommands, workspace.slashCommands); + const failedAgent = failedAgents.optionDescriptors?.[0]; + assert.deepStrictEqual(failedAgent?.type === "select" ? failedAgent.options : [], []); + agentsUnavailable = false; + assert.deepStrictEqual( + (yield* instance.snapshotForCwd!("/unavailable")).optionDescriptors, + other.optionDescriptors, + ); + assert.deepStrictEqual([...new Set(directories)], ["/work", "/other", "/unavailable"]); + assert.deepStrictEqual((yield* instance.snapshot.getSnapshot).models, workspace.models); + assert.include(requested, "/api/skill"); + assert.deepStrictEqual(serverStarts, []); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.effect("loads OpenCode 1 project agents through a client bound to each directory", () => Effect.gen(function* () { - serverStarts.length = 0; - const requested: Array = []; - const server = replayOpenCodeServer( - { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, - "secret", - requested, - ); + const directories: string[] = []; + const runtime = { + ...openCode2Runtime, + connectToOpenCodeServer: () => + Effect.succeed({ url: "http://127.0.0.1:4096", version: "1.18.32" }), + createOpenCodeSdkClient: (input: { directory: string }) => ({ + app: { + agents: async () => { + directories.push(input.directory); + if (input.directory === "/unavailable") { + throw new Error("Agent discovery unavailable"); + } + return { + data: [ + { name: "build", mode: "primary", hidden: false }, + { + name: input.directory === "/work" ? "readonly" : "audit", + mode: "all", + hidden: false, + }, + { name: "hidden", mode: "all", hidden: true }, + { name: "explore", mode: "subagent", hidden: false }, + ], + }; + }, + }, + command: { list: async () => ({ data: [{ name: "review", hints: [] }] }) }, + }), + loadOpenCodeSkills: () => Effect.succeed([{ name: "review", location: "/work/SKILL.md" }]), + loadOpenCodeInventory: () => + Effect.succeed({ + providerList: { all: [], connected: [], default: {} }, + agents: [], + skills: [], + }), + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; const instance = yield* create( - { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, - server, - ); - - const workspace = yield* instance.snapshotForCwd!("/work"); - assert.includeMembers( - workspace.skills.map((skill) => skill.name), - ["plum", "opencode"], - ); - assert.deepStrictEqual( - workspace.slashCommands.map((command) => command.name), - ["compact", "init", "review", "bee"], - ); - assert.include(requested, "/api/skill"); - assert.deepStrictEqual(serverStarts, []); + { serverUrl: "http://127.0.0.1:4096", customModels: ["openai/gpt-5.4"] }, + replayOpenCodeServer(OPENCODE_1_RESPONSES, ""), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + for (const [cwd, name] of [ + ["/work", "readonly"], + ["/other", "audit"], + ["/unavailable", null], + ["/work", "readonly"], + ] as const) { + const workspace = yield* instance.snapshotForCwd!(cwd); + const agent = workspace.optionDescriptors?.[0]; + assert.deepStrictEqual( + agent?.type === "select" ? agent.options.map((option) => option.id) : [], + name === null ? [] : ["build", name], + ); + assert.deepStrictEqual( + workspace.skills.map((skill) => skill.name), + ["review"], + ); + assert.deepStrictEqual( + workspace.slashCommands.map((command) => command.name), + ["compact", "review"], + ); + assert.deepStrictEqual(workspace.models, (yield* instance.snapshot.getSnapshot).models); + } + assert.deepStrictEqual(directories, ["/work", "/other", "/unavailable", "/work"]); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a16516c40a27..081ba39547a2 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -37,6 +37,7 @@ import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; import { checkOpenCodeProviderStatus, + openCodeAgentOptionDescriptor, loadOpenCode2Workspace, makeOpenCode2ModelLoader, makePendingOpenCodeProvider, @@ -313,15 +314,23 @@ export const OpenCodeDriver: ProviderDriver ), ), ); - // A 2.x server lists skills and commands per directory, so one server + // A 2.x server lists agents, skills and commands per directory, so one server // answers every workspace. Its event stream says when a directory it had // not served yet finished scanning. + const loadedOpenCode2Directories = new WeakMap< + OpenCode2Server.OpenCode2Connection, + Set + >(); const listOpenCode2Workspace = (cwd: string) => - openCode2Server.withConnection(({ client, events }) => + openCode2Server.withConnection((connection) => Effect.gen(function* () { + const { client, events } = connection; + const loadedDirectories = + loadedOpenCode2Directories.get(connection) ?? new Set(); + loadedOpenCode2Directories.set(connection, loadedDirectories); const location = { directory: cwd }; const scanned = yield* Deferred.make(); - const pending = new Set(["command.updated", "skill.updated"]); + const pending = new Set(["command.updated", "skill.updated", "agent.updated"]); const stream = yield* events.pipe(Effect.option); if (stream._tag === "Some") { yield* stream.value.pipe( @@ -337,16 +346,23 @@ export const OpenCodeDriver: ProviderDriver Effect.forkScoped, ); } - return yield* loadOpenCode2Workspace( + const workspace = yield* loadOpenCode2Workspace( Effect.all( { + agents: client.agent.list({ location }).pipe( + Effect.map((list) => list.data), + Effect.orElseSucceed(() => []), + ), skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)), commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)), }, { concurrency: "unbounded" }, ), Deferred.await(scanned), + loadedDirectories.has(cwd), ); + loadedDirectories.add(cwd); + return workspace; }).pipe(Effect.scoped), ); const serverOwner = yield* OpenCodeServerOwner.make({ @@ -404,6 +420,7 @@ export const OpenCodeDriver: ProviderDriver ) => Effect.all( { + agents: OpenCodeRuntime.loadOpenCodeAgents(client).pipe(Effect.orElseSucceed(() => [])), skills: openCodeRuntime.loadOpenCodeSkills(client), commands: OpenCodeRuntime.loadOpenCodeCommands(client).pipe( Effect.timeout("10 seconds"), @@ -503,14 +520,19 @@ export const OpenCodeDriver: ProviderDriver new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to list OpenCode commands and skills for '${cwd}'`, + detail: `Failed to list OpenCode agents, commands and skills for '${cwd}'`, cause, }), ), ), ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ + Effect.map(([machineSnapshot, { agents, skills, commands }]) => ({ ...machineSnapshot, + optionDescriptors: [ + openCodeAgentOptionDescriptor( + agents.map((agent) => ({ ...agent, name: agent.id })), + ), + ], skills: openCode2SkillsToServerProviderSkills(skills), slashCommands: openCode2CommandsToServerProviderSlashCommands(commands), })), @@ -519,8 +541,9 @@ export const OpenCodeDriver: ProviderDriver snapshot.getSnapshot, loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")), ]).pipe( - Effect.map(([machineSnapshot, { skills, commands }]) => ({ + Effect.map(([machineSnapshot, { agents, skills, commands }]) => ({ ...machineSnapshot, + optionDescriptors: [openCodeAgentOptionDescriptor(agents)], skills: openCodeSkillsToServerProviderSkills(skills), slashCommands: openCodeCommandsToServerProviderSlashCommands(commands), })), @@ -529,7 +552,7 @@ export const OpenCodeDriver: ProviderDriver new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to probe OpenCode commands and skills for '${cwd}'`, + detail: `Failed to probe OpenCode agents, commands and skills for '${cwd}'`, cause, }), ), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index e60515694ba5..35bbae532e93 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -815,6 +815,10 @@ const builtinSkills = [ { id: "report", name: "Report", path: "/builtin/report.md" }, ]; const scanned: OpenCode2Workspace = { + agents: [ + { id: "build", mode: "primary", hidden: false }, + { id: "readonly", mode: "all", hidden: false }, + ], commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], skills: [ ...builtinSkills, @@ -830,7 +834,29 @@ const scanned: OpenCode2Workspace = { it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => Effect.gen(function* () { // A directory the server has not served yet lists no commands until its scan ends. - const reads: Array = [{ commands: [], skills: [] }, scanned]; + const reads: Array = [{ agents: [], commands: [], skills: [] }, scanned]; + const scanEnded = yield* Deferred.make(); + const load = yield* loadOpenCode2Workspace( + Effect.sync(() => reads.shift()!), + Deferred.await(scanEnded), + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(reads.length, 1); + yield* Deferred.succeed(scanEnded, undefined); + NodeAssert.deepEqual(yield* Fiber.join(load), scanned); + }), +); + +it.effect("waits for project agents when OpenCode 2 already lists built-ins", () => + Effect.gen(function* () { + const reads: Array = [ + { + ...scanned, + agents: [{ id: "build", mode: "primary", hidden: false }], + commands: builtinCommands, + }, + scanned, + ]; const scanEnded = yield* Deferred.make(); const load = yield* loadOpenCode2Workspace( Effect.sync(() => reads.shift()!), @@ -845,7 +871,7 @@ it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () it.effect("takes a served OpenCode 2 directory's first listing as is", () => Effect.gen(function* () { - const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); + const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never, true); NodeAssert.deepEqual(workspace, scanned); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 96d8bf41bcd7..e8aae18bf059 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,6 +1,7 @@ import { type ModelCapabilities, type OpenCodeSettings, + type SelectProviderOptionDescriptor, type ServerProviderModel, type ServerProviderSkill, type ServerProviderSlashCommand, @@ -163,10 +164,30 @@ function inferDefaultVariant( return undefined; } -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { +function inferDefaultAgent(agents: ReadonlyArray<{ readonly name: string }>): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } +export function openCodeAgentOptionDescriptor( + agents: ReadonlyArray>, +): SelectProviderOptionDescriptor { + const visible = agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + const defaultAgent = inferDefaultAgent(visible); + return { + id: "agent", + label: "Agent", + type: "select", + options: visible.map((agent) => ({ + id: agent.name, + label: titleCaseSlug(agent.name), + ...(agent.name === defaultAgent ? { isDefault: true } : {}), + })), + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }; +} + const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { @@ -213,15 +234,7 @@ function openCodeCapabilitiesForModel(input: { ? { id: value, label: titleCaseSlug(value), isDefault: true as const } : { id: value, label: titleCaseSlug(value) }, ); - const primaryAgents = input.agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); - const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions = primaryAgents.map((agent) => - defaultAgent === agent.name - ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } - : { id: agent.name, label: titleCaseSlug(agent.name) }, - ); + const agentDescriptor = openCodeAgentOptionDescriptor(input.agents); return createModelCapabilities({ optionDescriptors: [ ...(variantOptions.length > 0 @@ -235,17 +248,7 @@ function openCodeCapabilitiesForModel(input: { }, ] : []), - ...(agentOptions.length > 0 - ? [ - { - id: "agent", - label: "Agent", - type: "select" as const, - options: agentOptions, - ...(defaultAgent ? { currentValue: defaultAgent } : {}), - }, - ] - : []), + ...(agentDescriptor.options.length > 0 ? [agentDescriptor] : []), ], }); } @@ -410,8 +413,13 @@ export const makeOpenCode2ModelLoader = ( ); }); -/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ +/** A workspace's agents, skills and commands as OpenCode 2 lists them for its directory. */ export interface OpenCode2Workspace { + readonly agents: ReadonlyArray<{ + readonly id: string; + readonly mode: Agent["mode"]; + readonly hidden: boolean; + }>; readonly skills: ReadonlyArray<{ readonly id: string; readonly name: string; @@ -425,19 +433,18 @@ export interface OpenCode2Workspace { } /** - * A server scans a directory it has not served yet in the background: its - * first read lists no commands at all (not even the built-ins), and - * `command.updated` plus `skill.updated` for the directory end the scan. A - * directory it already serves lists everything at once. `scanned` must be - * listening before `read` runs, since the read is what starts the scan. + * Built-in catalogs can appear before a directory's project scan ends. + * Listen for `scanned` before the first read starts that scan; subsequent + * reads of a directory already loaded by this connection skip the wait. */ export const loadOpenCode2Workspace = ( read: Effect.Effect, scanned: Effect.Effect, + alreadyLoaded = false, ) => Effect.gen(function* () { const first = yield* read; - if (first.commands.length > 0) return first; + if (alreadyLoaded) return first; yield* scanned.pipe(Effect.timeoutOption("10 seconds")); return yield* read; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ee52a71bba5f..49e06e71ebff 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -608,7 +608,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test }); describe("ProviderRegistryLive", () => { - it("stores workspace skills and commands without changing machine metadata", () => { + it("stores workspace skills, commands and options without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), @@ -627,18 +627,28 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test checkedAt: "2026-03-25T00:01:00.000Z", slashCommands: [{ name: "project" }], skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], - } satisfies ServerProvider; + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "readonly", label: "Readonly" }], + }, + ], + } satisfies ProviderWorkspaceSnapshot; const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); assert.deepStrictEqual(result.slashCommands, provider.slashCommands); assert.deepStrictEqual(result.skills, provider.skills); + assert.strictEqual(result.models, provider.models); assert.deepStrictEqual(result.workspaceSnapshots, [ { cwd: "/project", checkedAt: scopedSnapshot.checkedAt, slashCommands: scopedSnapshot.slashCommands, skills: scopedSnapshot.skills, + optionDescriptors: scopedSnapshot.optionDescriptors, }, ]); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index b9c28c155283..3f954110b33c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -105,6 +105,9 @@ export function upsertProviderWorkspaceSnapshot( : scopedSnapshot.slashCommands, ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), skills: scopedSnapshot.skills, + ...(scopedSnapshot.optionDescriptors === undefined + ? {} + : { optionDescriptors: scopedSnapshot.optionDescriptors }), } satisfies NonNullable[number]; return { ...provider, diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index cb86075616d1..8f22f2c588a8 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -61,7 +61,7 @@ export interface ProviderDriverMetadata { } export type ProviderWorkspaceSnapshot = ServerProvider & - Pick; + Pick; /** * One materialized provider instance. Held by the registry, looked up by diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index f14a93257486..89354cdae3b8 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -207,6 +207,12 @@ export const loadOpenCodeCommands = (client: OpencodeClient) => ), ); +/** Lists agents in the SDK client's directory, including project-local definitions. */ +export const loadOpenCodeAgents = (client: OpencodeClient) => + runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( + Effect.map((result) => result.data ?? []), + ); + export interface ParsedOpenCodeModelSlug { readonly providerID: string; readonly modelID: string; @@ -931,12 +937,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); - const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( - Effect.map((result) => result.data ?? []), - Effect.orElseSucceed((): ReadonlyArray => []), - ); - const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( Effect.map((result) => @@ -954,7 +954,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.all( [ loadProviders(client), - loadAgents(client), + loadOpenCodeAgents(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])), loadSkills(client), loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])), ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 78c5f113162e..5d9093623a0d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -398,7 +398,10 @@ import { import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation"; -import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; +import { + resolveProviderForCwd, + resolveProviderSkillsForCwd, +} from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useProjectClone } from "../state/projectClones"; @@ -3161,12 +3164,23 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); + const gitCwd = activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThread?.worktreePath ?? null, + }) + : null; const providerInstanceEntries = useMemo( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries( + providerStatuses.map((provider) => resolveProviderForCwd(provider, gitCwd)), + ), + settings, + ), ), - [providerStatuses, settings], + [providerStatuses, settings, gitCwd], ); const { selectedProviderEntry, requestedDriverKind } = useMemo( () => @@ -4018,12 +4032,6 @@ export default function ChatView(props: ChatViewProps) { : JSON.stringify([itemId, latestCheckpointCompletedAt]); }, [serverVisibleTurnItems, turnDiffSummaries]); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 36b2b2a6a271..06df5bd21b77 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1122,6 +1122,7 @@ import { getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + resolveProviderForCwd, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; @@ -2071,12 +2072,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Instance-aware projection of the wire provider list. One entry per // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. + const workspaceProviders = useMemo( + () => providerStatuses.map((provider) => resolveProviderForCwd(provider, gitCwd)), + [providerStatuses, gitCwd], + ); const providerInstanceEntries = useMemo>( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + applyProviderInstanceSettings(deriveProviderInstanceEntries(workspaceProviders), settings), ), - [providerStatuses, settings], + [workspaceProviders, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const { @@ -2146,7 +2151,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, - providers: providerStatuses, + providers: workspaceProviders, selectedProvider, selectedInstanceId, threadModelSelection: activeThreadModelSelection, diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 44cd77b7e8d6..2c5cf431cd2c 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -1,6 +1,9 @@ +import { resolveProviderForCwd } from "@t3tools/client-runtime/providerSkills"; import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, type ProviderOptionDescriptor, type ProviderOptionSelection, type ServerProviderModel, @@ -73,6 +76,55 @@ const ULTRATHINK_FRAME_CLASSES = { } as const; describe("getComposerProviderState", () => { + it("dispatches a project agent from its cwd catalog with legacy plan mode disabled", () => { + const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + enabled: true, + installed: true, + status: "ready", + version: "2.0.18", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + skills: [], + slashCommands: [], + models: modelWith([ + selectDescriptor("agent", [{ id: "build", label: "Build", isDefault: true }]), + ]), + workspaceSnapshots: [ + { + cwd: "/work", + checkedAt: "2026-01-01T00:00:00.000Z", + skills: [], + slashCommands: [], + optionDescriptors: [ + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + { id: "readonly", label: "Readonly" }, + ]), + ], + }, + ], + }; + const scoped = resolveProviderForCwd(provider, "/work"); + const state = getComposerProviderState({ + provider: provider.driver, + model: MODEL, + models: scoped.models, + modelOptions: selections(["agent", "readonly"]), + planModeEnabled: false, + }); + expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "readonly"])); + const global = getComposerProviderState({ + provider: provider.driver, + model: MODEL, + models: provider.models, + modelOptions: selections(["agent", "readonly"]), + planModeEnabled: false, + }); + expect(global.modelOptionsForDispatch).toEqual(selections(["agent", "build"])); + }); it("derives a stable prompt injection state for ordinary prompt edits", () => { expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 30b804d176ac..50f80817a606 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -7,6 +7,7 @@ import { getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + resolveProviderForCwd, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, @@ -275,3 +276,64 @@ describe("workspace provider snapshots", () => { expect(hasCompleteProviderWorkspaceSnapshot(provider, null)).toBe(false); }); }); + +describe("resolveProviderForCwd", () => { + it("replaces only workspace option ids and leaves the global catalog untouched", () => { + const variant = { + id: "variant", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }; + const globalAgent = { + id: "agent", + label: "Agent", + type: "select" as const, + options: [{ id: "build", label: "Build" }], + }; + const catalog: ServerProvider = { + ...provider, + models: [ + { + slug: "openai/gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { optionDescriptors: [variant, globalAgent] }, + }, + { slug: "custom/model", name: "Custom", isCustom: true, capabilities: null }, + ], + workspaceSnapshots: ["readonly", "audit"].map((name) => ({ + ...provider.workspaceSnapshots[0]!, + cwd: "/workspace/" + name, + optionDescriptors: [{ ...globalAgent, options: [{ id: name, label: name }] }], + })), + }; + for (const name of ["readonly", "audit"]) { + const scoped = resolveProviderForCwd(catalog, "/workspace/" + name); + expect(scoped.models[0]?.capabilities?.optionDescriptors).toEqual([ + variant, + { ...globalAgent, options: [{ id: name, label: name }] }, + ]); + expect(scoped.models[1]?.capabilities?.optionDescriptors).toEqual([ + { ...globalAgent, options: [{ id: name, label: name }] }, + ]); + } + expect(catalog.models[0]?.capabilities?.optionDescriptors).toEqual([variant, globalAgent]); + expect(resolveProviderForCwd(catalog, "/workspace/unknown")).toBe(catalog); + expect(resolveProviderForCwd(catalog, null)).toBe(catalog); + expect(resolveProviderForCwd(provider, "/workspace/project-a")).toBe(provider); + const emptyAgents: ServerProvider = { + ...catalog, + workspaceSnapshots: [ + { + ...provider.workspaceSnapshots[0]!, + optionDescriptors: [{ ...globalAgent, options: [] }], + }, + ], + }; + expect( + resolveProviderForCwd(emptyAgents, "/workspace/project-a").models[0]?.capabilities + ?.optionDescriptors, + ).toEqual([variant, { ...globalAgent, options: [] }]); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b52377b0165e..40d4d7527aa2 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -119,6 +119,31 @@ export function hasCompleteProviderWorkspaceSnapshot( return Boolean(snapshot && !snapshot.slashCommandsPending); } +/** Applies workspace options without changing the provider's machine-wide catalog. */ +export function resolveProviderForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider { + const descriptors = resolveProviderWorkspaceSnapshot(provider, cwd)?.optionDescriptors; + if (descriptors === undefined) return provider; + const overridden = new Set(descriptors.map((descriptor) => descriptor.id)); + return { + ...provider, + models: provider.models.map((model) => ({ + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: [ + ...(model.capabilities?.optionDescriptors ?? []).filter( + (descriptor) => !overridden.has(descriptor.id), + ), + ...descriptors, + ], + }, + })), + }; +} + export function resolveProviderSkillsForCwd( provider: ServerProvider, cwd: string | null | undefined, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index fdb6b82b3dbc..2e1ebab10fc8 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -38,6 +38,14 @@ describe("ServerProvider", () => { slashCommands: [{ name: "compact" }], ...(pending === undefined ? {} : { slashCommandsPending: pending }), skills: [{ name: "project", path: "/workspace/project/SKILL.md", enabled: true }], + optionDescriptors: [ + { + id: "agent", + label: "Agent", + type: "select", + options: [{ id: "readonly", label: "Readonly" }], + }, + ], }; const parsed = decodeServerProvider({ ...baseProviderSnapshot, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 81e0899777a5..b5931bdbb746 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -23,7 +23,7 @@ import { ResolvedKeybindingsConfig, } from "./keybindings.ts"; import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; -import { ModelCapabilities } from "./model.ts"; +import { ModelCapabilities, ProviderOptionDescriptor } from "./model.ts"; import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; @@ -128,6 +128,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ /** Skills are available, but command discovery still needs a retry. */ slashCommandsPending: Schema.optional(Schema.Boolean), skills: Schema.Array(ServerProviderSkill), + /** Workspace options replace descriptors with the same id in the model catalog. */ + optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }); export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;