diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4b5a93cd1f75..7c7fc22d00ad 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -249,6 +249,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > {props.pendingUserInput.questions.map((question) => { const draft = props.drafts[question.id]; + const hasOptions = question.options.length > 0; return ( @@ -257,49 +258,51 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.question} - - {question.options.map((option) => { - const selected = isPendingUserInputOptionSelected(draft, option.label); - const description = - option.description !== option.label ? option.description : undefined; - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question, - option.label, - ) - } - > - - - {option.label} - - {description ? ( - - {description} + {hasOptions ? ( + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + const description = + option.description !== option.label ? option.description : undefined; + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } + > + + + {option.label} - ) : null} - - - ); - })} - + {description ? ( + + {description} + + ) : null} + + + ); + })} + + ) : null} @@ -307,7 +310,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { } onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} - placeholder="Or type a custom answer" + placeholder={hasOptions ? "Or type a custom answer" : "Type your answer"} className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" /> diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..683723b21b4c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -14,6 +14,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -45,6 +46,30 @@ const multiSelectQuestion = { } as const; describe("pending user input answers", () => { + it("keeps free-form questions with no preset options", () => { + const pending = derivePendingUserInputs([ + makeActivity({ + id: EventId.make("user-input-free-form"), + kind: "user-input.requested", + summary: "User input requested", + createdAt: "2026-04-01T00:00:00.000Z", + payload: { + requestId: "req-user-input-free-form", + questions: [ + { + id: "name", + header: "Name", + question: "What should this be called?", + options: [], + }, + ], + }, + }), + ]); + + expect(pending[0]?.questions[0]?.options).toEqual([]); + }); + it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..b7c0a560bdd1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -203,9 +203,6 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { - return null; - } return { id: question.id, header: question.header, diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..e4cf70bc3c5b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -2,6 +2,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -27,6 +28,7 @@ const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANC const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; +const failFirstLoadSession = process.env.T3_ACP_FAIL_FIRST_LOAD_SESSION === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; @@ -36,7 +38,23 @@ const emitStaleXAiPromptCompleteBeforeSecondHang = const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; +const failPromptNumber = Number(process.env.T3_ACP_FAIL_PROMPT_NUMBER ?? "0"); +const activePromptErrorNumber = Number(process.env.T3_ACP_ACTIVE_PROMPT_ERROR_NUMBER ?? "0"); const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const failSetConfigOptionNumber = Number(process.env.T3_ACP_FAIL_SET_CONFIG_OPTION_NUMBER ?? "0"); +const elicitDuringCreateSession = process.env.T3_ACP_ELICIT_DURING_CREATE_SESSION === "1"; +const elicitBooleanDuringCreateSession = + process.env.T3_ACP_ELICIT_BOOLEAN_DURING_CREATE_SESSION === "1"; +const elicitNumberDuringCreateSession = + process.env.T3_ACP_ELICIT_NUMBER_DURING_CREATE_SESSION === "1"; +const elicitUrlDuringCreateSession = process.env.T3_ACP_ELICIT_URL_DURING_CREATE_SESSION === "1"; +const elicitComplexDuringCreateSession = + process.env.T3_ACP_ELICIT_COMPLEX_DURING_CREATE_SESSION === "1"; +const elicitDuringPrompt = process.env.T3_ACP_ELICIT_DURING_PROMPT === "1"; +const elicitFirstPrompt = process.env.T3_ACP_ELICIT_FIRST_PROMPT === "1"; +const elicitDuringLoadSession = process.env.T3_ACP_ELICIT_DURING_LOAD_SESSION === "1"; +const elicitationResponseLogPath = process.env.T3_ACP_ELICITATION_RESPONSE_LOG_PATH; +const emitPendingToolCall = process.env.T3_ACP_EMIT_PENDING_TOOL_CALL === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -54,6 +72,8 @@ let currentReasoning = "medium"; let currentContext = "272k"; let currentFast = false; let promptCount = 0; +let loadSessionCount = 0; +let setConfigOptionCount = 0; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); @@ -295,6 +315,7 @@ function modelState(): AcpSchema.SessionModelState { const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; + const promptCancellations = new Map>(); yield* agent.handleInitialize((request) => Effect.sync(() => { @@ -302,7 +323,7 @@ const program = Effect.gen(function* () { request.clientCapabilities?._meta?.parameterizedModelPicker === true; return { protocolVersion: 1, - agentCapabilities: { loadSession: true }, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, }; }), ); @@ -310,11 +331,104 @@ const program = Effect.gen(function* () { yield* agent.handleAuthenticate(() => Effect.succeed({})); yield* agent.handleCreateSession(() => - Effect.succeed({ - sessionId, - modes: modeState(), - models: modelState(), - configOptions: configOptions(), + Effect.gen(function* () { + if (elicitDuringCreateSession) { + const response = yield* agent.client.elicit({ + sessionId, + message: "Describe the desired workspace.", + mode: "form", + requestedSchema: { + type: "object", + title: "Workspace request", + properties: {}, + }, + }); + if (elicitationResponseLogPath) { + yield* Effect.sync(() => { + const content = + response.action.action === "accept" ? response.action.content?.response : undefined; + NodeFS.appendFileSync( + elicitationResponseLogPath, + `${response.action.action}\t${String(content ?? "")}\n`, + ); + }); + } + } + if (elicitBooleanDuringCreateSession) { + yield* agent.client.elicit({ + sessionId, + message: "Enable fast mode?", + mode: "form", + requestedSchema: { + type: "object", + title: "Fast mode", + properties: { + enabled: { type: "boolean", title: "Enabled" }, + }, + required: ["enabled"], + }, + }); + } + if (elicitNumberDuringCreateSession) { + const response = yield* agent.client.elicit({ + sessionId, + message: "Choose a worker count.", + mode: "form", + requestedSchema: { + type: "object", + title: "Worker count", + properties: { + count: { type: "number", title: "Count" }, + }, + required: ["count"], + }, + }); + if (elicitationResponseLogPath) { + yield* Effect.sync(() => { + NodeFS.appendFileSync(elicitationResponseLogPath, `${response.action.action}\n`); + }); + } + } + if (elicitUrlDuringCreateSession) { + yield* agent.client.elicit({ + sessionId, + message: "Authorize OpenCode in your browser.", + mode: "url", + elicitationId: "mock-url-elicitation", + url: "https://example.com/opencode/authorize", + }); + } + if (elicitComplexDuringCreateSession) { + yield* agent.client.elicit({ + sessionId, + message: "Choose deployment targets.", + mode: "form", + requestedSchema: { + type: "object", + title: "Deployment", + properties: { + targets: { + type: "array", + title: "Targets", + items: { + anyOf: [ + { const: "web", title: "Web" }, + { const: "mobile", title: "Mobile" }, + ], + }, + }, + note: { type: "string", title: "Optional note" }, + }, + required: ["targets"], + }, + }); + } + return { + sessionId, + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; }), ); @@ -343,9 +457,25 @@ const program = Effect.gen(function* () { yield* agent.handleLoadSession((request) => Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); - if (failLoadSession) { + loadSessionCount += 1; + if (failLoadSession || (failFirstLoadSession && loadSessionCount === 1)) { return yield* AcpError.AcpRequestError.internalError("Mock load session failure"); } + if (elicitDuringLoadSession) { + yield* agent.client.elicit({ + sessionId: requestedSessionId, + message: "Continue loading the session?", + mode: "form", + requestedSchema: { + type: "object", + title: "Continue loading", + properties: { + proceed: { type: "boolean", title: "Proceed" }, + }, + required: ["proceed"], + }, + }); + } if (hangLoadSessionAfterReplay || delayLoadSessionAfterReplay) { emitLoadReplayNotifications(requestedSessionId); yield* agent.client.sessionUpdate({ @@ -380,6 +510,8 @@ const program = Effect.gen(function* () { }), ); + yield* agent.handleCloseSession(() => Effect.succeed({})); + yield* agent.handleSetSessionModel((request) => Effect.gen(function* () { if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { @@ -398,12 +530,16 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { + setConfigOptionCount += 1; if (exitOnSetConfigOption) { return yield* Effect.sync(() => { process.exit(7); }); } - if (failSetConfigOption) { + if ( + failSetConfigOption || + (failSetConfigOptionNumber > 0 && setConfigOptionCount === failSetConfigOptionNumber) + ) { return yield* AcpError.AcpRequestError.invalidParams( "Mock invalid params for session/set_config_option", { @@ -437,6 +573,10 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); cancelledSessions.add(cancelledSessionId); + const cancellation = promptCancellations.get(cancelledSessionId); + if (cancellation) { + yield* Deferred.succeed(cancellation, undefined); + } if (emitLateUpdateAfterCancel) { yield* Effect.sleep("50 millis"); yield* Effect.sync(() => { @@ -461,10 +601,58 @@ const program = Effect.gen(function* () { yield* Effect.sleep(`${promptDelayMs} millis`); } - if (failPrompt) { + if (activePromptErrorNumber > 0 && promptCount === activePromptErrorNumber) { + return yield* AcpError.AcpRequestError.internalError( + "Session already has an active ACP prompt", + ); + } + + if (failPrompt || (failPromptNumber > 0 && promptCount === failPromptNumber)) { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + if (elicitDuringPrompt || (elicitFirstPrompt && promptCount === 1)) { + const response = yield* agent.client.elicit({ + sessionId: requestedSessionId, + message: "Choose a workspace mode.", + mode: "form", + requestedSchema: { + type: "object", + title: "Workspace mode", + properties: { + mode: { type: "string", title: "Mode", enum: ["build", "plan"] }, + }, + required: ["mode"], + }, + }); + if (elicitationResponseLogPath) { + yield* Effect.sync(() => { + NodeFS.appendFileSync(elicitationResponseLogPath, `${response.action.action}\n`); + }); + } + return { + stopReason: + response.action.action === "cancel" || cancelledSessions.delete(requestedSessionId) + ? "cancelled" + : "end_turn", + }; + } + + if (emitPendingToolCall) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "pending-tool-call-1", + title: "Pending tool", + kind: "other", + status: "pending", + rawInput: {}, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { return { stopReason: "end_turn", @@ -518,9 +706,19 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { + if (hangPromptForever) { return yield* Effect.never; } + if (hangFirstPromptForever && promptCount === 1) { + const cancellation = yield* Deferred.make(); + promptCancellations.set(requestedSessionId, cancellation); + if (!cancelledSessions.delete(requestedSessionId)) { + yield* Deferred.await(cancellation); + } + promptCancellations.delete(requestedSessionId); + cancelledSessions.delete(requestedSessionId); + return { stopReason: "cancelled" }; + } if (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..bd1971a08baa 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -33,7 +33,7 @@ import { } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import { isOpenCode2BinaryPath, OpenCodeRuntime } from "../opencodeRuntime.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -43,6 +43,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, @@ -130,11 +131,32 @@ export const OpenCodeDriver: ProviderDriver accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); - const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }); + // Upgrade the default binary to the v2 preview when one resolves, so + // users with `opencode2` installed get it without touching settings. + // Skipped while the instance is disabled so a boot-time probe doesn't + // spawn subprocesses for providers the user never turned on; enabling + // rebuilds the instance and resolves then. While disabled, every + // consumer consistently sees the unresolved path. + const configuredConfig = { ...config, enabled } satisfies OpenCodeSettings; + const resolvedBinaryPath = enabled + ? yield* openCodeRuntime.resolveDefaultBinaryPath({ + binaryPath: configuredConfig.binaryPath, + environment: processEnv, + }) + : configuredConfig.binaryPath; + const effectiveConfig: OpenCodeSettings = { + ...configuredConfig, + binaryPath: resolvedBinaryPath, + }; + const maintenanceCapabilities = isOpenCode2BinaryPath(effectiveConfig.binaryPath) + ? makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }) + : yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..36fe194a6223 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -134,6 +134,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { external: Boolean(serverUrl), }; }), + resolveDefaultBinaryPath: ({ binaryPath }) => Effect.succeed(binaryPath), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..14956060db84 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1224,14 +1224,27 @@ export function makeOpenCodeAdapter( const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, + // An explicit password means the user targets their own + // authenticated server — skip background-service adoption. + serverPassword, ...(options?.environment ? { environment: options.environment } : {}), }); + // The connection's own password wins: spawned OpenCode 2 + // children always require theirs, and adopted background + // services carry the registration one. Explicit settings apply + // only to external servers, whose credentials we never guess. + const effectiveServerPassword = + server.serverPassword ?? (server.external ? serverPassword : undefined); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, - ...(server.external && serverPassword ? { serverPassword } : {}), + ...(effectiveServerPassword ? { serverPassword: effectiveServerPassword } : {}), }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // Skipped for external servers — including adopted background + // services — so we never write our endpoint into a daemon the + // user's other clients share. Those sessions run without the + // t3-code MCP tools. if (mcpSession && !server.external) { yield* runOpenCodeSdk("mcp.add", () => client.mcp.add({ diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..96bb7c50866f 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -62,6 +62,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url: "http://127.0.0.1:4301", exitCode: Effect.never, }), + resolveDefaultBinaryPath: ({ binaryPath }) => Effect.succeed(binaryPath), connectToOpenCodeServer: ({ serverUrl }) => Effect.gen(function* () { if (!serverUrl) { @@ -292,6 +293,79 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("accepts the opencode2 beta banner as a v2 preview", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode2 v0.0.0-beta-17595\n"; + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4", variants: {} }, + }, + }, + ], + default: {}, + }, + agents: [], + skills: [], + }; + + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ binaryPath: "/Users/test/.opencode/bin/opencode2" }), + process.cwd(), + ); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "0.0.0-beta-17595"); + const effort = snapshot.models[0]?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "effort" && descriptor.type === "select", + ); + NodeAssert.ok(effort && effort.type === "select"); + NodeAssert.deepEqual( + effort.options.map((option) => option.label), + ["Low", "Medium", "High"], + ); + NodeAssert.equal(effort.options.find((option) => option.isDefault)?.id, "medium"); + }), + ); + + it.effect("still rejects the same pre-minimum version from stable opencode", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 0.0.0\n"; + + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal( + snapshot.message, + "OpenCode v0.0.0 is too old. Upgrade to v1.14.19 or newer.", + ); + }), + ); + + it.effect("reports an invalid opencode2 version without stable upgrade advice", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "OpenCode preview\n"; + + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ binaryPath: "/Users/test/.opencode/bin/opencode2" }), + process.cwd(), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal( + snapshot.message, + "Failed to execute OpenCode CLI health check: Unable to determine the OpenCode 2.0 preview version from `opencode2 --version` output.", + ); + }), + ); + it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..b7ce223c5956 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -19,7 +19,9 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { + formatOpenCodeSlugLabel, OpenCodeRuntime, + isOpenCode2BinaryPath, openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; @@ -135,16 +137,6 @@ function formatOpenCodeProbeError(input: { }; } -function titleCaseSlug(value: string): string { - const segments: Array = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); -} - function inferDefaultVariant( providerID: string, variants: ReadonlyArray, @@ -168,18 +160,34 @@ function inferDefaultAgent(agents: ReadonlyArray): string | undefined { const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); +const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + ], +}); function openCodeCapabilitiesForModel(input: { readonly providerID: string; readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; + readonly isOpenCode2: boolean; }): ModelCapabilities { const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, + ? { id: value, label: formatOpenCodeSlugLabel(value), isDefault: true as const } + : { id: value, label: formatOpenCodeSlugLabel(value) }, ); const primaryAgents = input.agents.filter( (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), @@ -187,8 +195,8 @@ function openCodeCapabilitiesForModel(input: { 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) }, + ? { id: agent.name, label: formatOpenCodeSlugLabel(agent.name), isDefault: true as const } + : { id: agent.name, label: formatOpenCodeSlugLabel(agent.name) }, ); return createModelCapabilities({ optionDescriptors: [ @@ -203,6 +211,7 @@ function openCodeCapabilitiesForModel(input: { }, ] : []), + ...(input.isOpenCode2 ? (DEFAULT_OPENCODE2_MODEL_CAPABILITIES.optionDescriptors ?? []) : []), ...(agentOptions.length > 0 ? [ { @@ -218,7 +227,10 @@ function openCodeCapabilitiesForModel(input: { }); } -function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { +function flattenOpenCodeModels( + input: OpenCodeInventory, + isOpenCode2: boolean, +): ReadonlyArray { const connected = new Set(input.providerList.connected); const models: Array = []; @@ -243,6 +255,7 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray 0; + const isOpenCode2 = !isExternalServer && isOpenCode2BinaryPath(openCodeSettings.binaryPath); + const defaultCapabilities = isOpenCode2 + ? DEFAULT_OPENCODE2_MODEL_CAPABILITIES + : DEFAULT_OPENCODE_MODEL_CAPABILITIES; const fallback = (cause: unknown, version: string | null = null) => { const failure = formatOpenCodeProbeError({ @@ -343,7 +360,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, defaultCapabilities), probe: { installed: failure.installed, version, @@ -359,7 +376,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, defaultCapabilities), probe: { installed: false, version: null, @@ -390,22 +407,27 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (versionExit._tag === "Failure") { return fallback(Cause.squash(versionExit.cause)); } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + const previewVersion = isOpenCode2 + ? versionExit.value.stdout.match(/v?(\d+\.\d+\.\d+-beta(?:-[0-9A-Za-z.-]+)?)/)?.[1] + : undefined; + version = previewVersion ?? parseGenericCliVersion(versionExit.value.stdout) ?? null; if (!version) { return fallback( new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + isOpenCode2 + ? "Unable to determine the OpenCode 2.0 preview version from `opencode2 --version` output." + : `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, ), null, ); } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + if (!isOpenCode2 && compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, defaultCapabilities), probe: { installed: true, version, @@ -424,6 +446,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, serverUrl: openCodeSettings.serverUrl, + serverPassword: openCodeSettings.serverPassword, environment: resolvedEnvironment, }); return yield* openCodeRuntime.loadOpenCodeInventory( @@ -453,9 +476,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + flattenOpenCodeModels(inventoryExit.value, isOpenCode2), customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, + defaultCapabilities, ); const skills = flattenOpenCodeSkills(inventoryExit.value); const connectedCount = inventoryExit.value.providerList.connected.length; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..68f776ef8251 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -228,8 +229,10 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("releases a fully silent prompt when session/cancel is requested", () => - Effect.gen(function* () { + it.effect("waits for a fully silent prompt to acknowledge session/cancel", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-cancel-order-")); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + return Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); @@ -240,7 +243,7 @@ describe("AcpSessionRuntime", () => { .pipe(Effect.forkChild({ startImmediately: true })); yield* TestClock.adjust("500 millis"); - yield* runtime.cancel; + yield* runtime.cancelAndWait; const firstPromptResult = yield* Fiber.join(promptFiber); expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" }); @@ -249,6 +252,19 @@ describe("AcpSessionRuntime", () => { prompt: [{ type: "text", text: "second" }], }); expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); + + const promptAndCancelMethods = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string }) + .map((message) => message.method) + .filter((method) => method === "session/prompt" || method === "session/cancel"); + expect(promptAndCancelMethods).toEqual([ + "session/prompt", + "session/cancel", + "session/prompt", + ]); }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -257,18 +273,245 @@ describe("AcpSessionRuntime", () => { args: mockAgentArgs, env: { T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }); + + it.effect("closes and reloads a persistent session before the next prompt", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-reload-order-")); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + yield* runtime.reload; + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "after reload" }], + }); + + expect(result).toMatchObject({ stopReason: "end_turn" }); + const methods = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string }) + .map((message) => message.method) + .filter( + (method) => + method === "session/close" || method === "session/load" || method === "session/prompt", + ); + expect(methods).toEqual(["session/close", "session/load", "session/prompt"]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }); + + it.effect("fails open tool calls when reloading a persistent session", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + const failedToolCall = + yield* Deferred.make< + Extract + >(); + const eventFiber = yield* runtime.getEvents().pipe( + Stream.runForEach((event) => { + const rawPayload = event._tag === "ToolCallUpdated" ? event.rawPayload : undefined; + return event._tag === "ToolCallUpdated" && + typeof rawPayload === "object" && + rawPayload !== null && + "source" in rawPayload && + rawPayload.source === "session/reload" + ? Deferred.succeed(failedToolCall, event) + : Effect.void; + }), + Effect.forkChild, + ); + yield* runtime.prompt({ + prompt: [{ type: "text", text: "start a tool" }], + }); + + yield* runtime.reload; + expect(yield* Deferred.await(failedToolCall)).toMatchObject({ + _tag: "ToolCallUpdated", + toolCall: { toolCallId: "pending-tool-call-1", status: "failed" }, + rawPayload: { source: "session/reload" }, + }); + yield* Fiber.interrupt(eventFiber); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_EMIT_PENDING_TOOL_CALL: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("preserves negotiated config when reload completes on replay idle", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + const before = yield* runtime.getConfigOptions; + + yield* runtime.reload.pipe(Effect.timeout("2 seconds")); + + expect(before.length).toBeGreaterThan(0); + expect(yield* runtime.getConfigOptions).toEqual(before); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", + T3_ACP_LOAD_SESSION_DELAY_MS: "10000", }, }, cwd: process.cwd(), clientInfo: { name: "t3-test", version: "0.0.0" }, authMethodId: "test", + sessionLoadReplayIdleGap: "50 millis", + sessionLoadTimeout: "1 second", }), ), Effect.scoped, Effect.provide(NodeServices.layer), + TestClock.withLive, ), ); + it.effect("waits for reload before writing session configuration", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const loadElicitation = yield* Deferred.make(); + const continueLoad = yield* Deferred.make(); + yield* runtime.handleElicitation(() => + Deferred.succeed(loadElicitation, undefined).pipe( + Effect.andThen(Deferred.await(continueLoad)), + Effect.as({ action: { action: "cancel" as const } }), + ), + ); + yield* runtime.start(); + + const reloadFiber = yield* runtime.reload.pipe(Effect.forkChild); + yield* Deferred.await(loadElicitation); + const configFiber = yield* runtime.setModel("composer-2").pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(configFiber.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(continueLoad, undefined); + yield* Fiber.join(reloadFiber); + yield* Fiber.join(configFiber); + const methods = requestEvents.map((event) => `${event.method}:${event.status}`); + expect(methods.indexOf("session/load:succeeded")).toBeLessThan( + methods.indexOf("session/set_config_option:started"), + ); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_ELICIT_DURING_LOAD_SESSION: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + clientCapabilities: { elicitation: { form: {} } }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("keeps a failed reload terminal instead of restarting the ACP child", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const reloadExit = yield* Effect.exit(runtime.reload); + const promptExit = yield* Effect.exit( + runtime.prompt({ prompt: [{ type: "text", text: "after failed reload" }] }), + ); + const restartExit = yield* Effect.exit(runtime.start()); + + expect(reloadExit._tag).toBe("Failure"); + expect(promptExit._tag).toBe("Failure"); + expect(restartExit._tag).toBe("Failure"); + expect(requestEvents.map((event) => `${event.method}:${event.status}`)).toEqual([ + "initialize:started", + "initialize:succeeded", + "authenticate:started", + "authenticate:succeeded", + "session/new:started", + "session/new:succeeded", + "session/close:started", + "session/close:succeeded", + "session/load:started", + "session/load:failed", + ]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_FAIL_FIRST_LOAD_SESSION: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..13d11f505210 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -194,11 +194,15 @@ export class AcpSessionRuntime extends Context.Service< readonly prompt: ( payload: Omit, ) => Effect.Effect; + /** Closes and reloads the active persistent ACP session. */ + readonly reload: Effect.Effect; /** * Sends a real ACP `session/cancel` notification for the active session. * @see https://agentclientprotocol.com/protocol/schema#session/cancel */ readonly cancel: Effect.Effect; + /** Waits briefly for the active prompt to acknowledge cancellation. */ + readonly cancelAndWait: Effect.Effect; /** * Selects the active mode through the negotiated `mode` configuration option. * This is a no-op when the requested mode is already active. @@ -250,10 +254,12 @@ interface AcpStartedState extends AcpSessionRuntimeStartResult {} type AcpStartState = | { readonly _tag: "NotStarted" } + | { readonly _tag: "Closed" } | { readonly _tag: "Starting"; readonly deferred: Deferred.Deferred; } + | { readonly _tag: "Reloading"; readonly result: AcpStartedState } | { readonly _tag: "Started"; readonly result: AcpStartedState }; interface AcpAssistantSegmentState { @@ -388,7 +394,7 @@ export const make = ( // One runtime projects one root ACP session. Child-session updates need // explicit lineage routing and must never be flattened into this stream. if ( - startState._tag !== "Started" || + (startState._tag !== "Started" && startState._tag !== "Reloading") || notification.sessionId !== startState.result.sessionId ) { return; @@ -419,12 +425,16 @@ export const make = ( const getStartedState = Effect.gen(function* () { const state = yield* Ref.get(startStateRef); - if (state._tag === "Started") { + if (state._tag === "Started" || state._tag === "Reloading") { return state.result; } + const detail = + state._tag === "Closed" + ? "ACP session runtime is closed after a failed reload" + : "ACP session runtime has not been started"; return yield* new EffectAcpErrors.AcpTransportError({ - detail: "ACP session runtime has not been started", - cause: "ACP session runtime has not been started", + detail, + cause: detail, }); }); @@ -490,7 +500,7 @@ export const make = ( current ? { ...current, currentModeId: modeId } : current, ); - const setConfigOption = ( + const setConfigOptionUnserialized = ( configId: string, value: string | boolean, ): Effect.Effect => @@ -527,6 +537,60 @@ export const make = ( ), ), ); + const setConfigOption = (configId: string, value: string | boolean) => + promptSerializationSemaphore.withPermit(setConfigOptionUnserialized(configId, value)); + + const loadSession = ( + sessionId: string, + initializeResult: EffectAcpSchema.InitializeResponse, + ) => { + const payload = { + sessionId, + cwd: options.cwd, + mcpServers: options.mcpServers ?? [], + } satisfies EffectAcpSchema.LoadSessionRequest; + const timeout = Duration.fromInputUnsafe( + options.sessionLoadTimeout ?? defaultSessionLoadTimeout, + ); + const idleGap = Duration.fromInputUnsafe( + options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, + ); + + return Effect.gen(function* () { + yield* Ref.set( + sessionLoadGateRef, + Option.some({ active: true, lastActivityAtMillis: undefined, idleGap, initializeResult }), + ); + yield* logRequest({ method: "session/load", payload, status: "started" }); + const idleFiber = yield* waitForSessionLoadReplayIdle({ + gateRef: sessionLoadGateRef, + }).pipe(Effect.forkIn(runtimeScope)); + return yield* Effect.raceFirst(acp.agent.loadSession(payload), Fiber.join(idleFiber)).pipe( + Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), + Effect.timeoutOption(timeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.tap((result) => + logRequest({ method: "session/load", payload, status: "succeeded", result }), + ), + Effect.onError((cause) => + logRequest({ method: "session/load", payload, status: "failed", cause }), + ), + ); + }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); + }; const startOnce = Effect.gen(function* () { const initializePayload = { @@ -557,79 +621,8 @@ export const make = ( | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; if (options.resumeSessionId) { - const loadPayload = { - sessionId: options.resumeSessionId, - cwd: options.cwd, - mcpServers: options.mcpServers ?? [], - } satisfies EffectAcpSchema.LoadSessionRequest; - const sessionLoadTimeout = Duration.fromInputUnsafe( - options.sessionLoadTimeout ?? defaultSessionLoadTimeout, - ); - const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( - options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, - ); - - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - active: true, - lastActivityAtMillis: undefined, - idleGap: sessionLoadReplayIdleGap, - initializeResult, - }), - ); - sessionId = options.resumeSessionId; - sessionSetupResult = yield* Effect.gen(function* () { - yield* logRequest({ - method: "session/load", - payload: loadPayload, - status: "started", - }); - - const idleFiber = yield* waitForSessionLoadReplayIdle({ - gateRef: sessionLoadGateRef, - }).pipe(Effect.forkIn(runtimeScope)); - const loaded = yield* Effect.raceFirst( - acp.agent.loadSession(loadPayload), - Fiber.join(idleFiber), - ).pipe( - Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), - Effect.timeoutOption(sessionLoadTimeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/load", - detail: "session/load timed out waiting for RPC response or replay idle gap", - cause: undefined, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.tap((result) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "succeeded", - result, - }), - ), - Effect.onError((cause) => - logRequest({ - method: "session/load", - payload: loadPayload, - status: "failed", - cause, - }), - ), - ); - - return loaded; - }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); + sessionSetupResult = yield* loadSession(sessionId, initializeResult); } else { const createPayload = { cwd: options.cwd, @@ -664,9 +657,17 @@ export const make = ( const effect = yield* Ref.modify(startStateRef, (state) => { switch (state._tag) { case "Started": + case "Reloading": return [Effect.succeed(state.result), state] as const; case "Starting": return [Deferred.await(state.deferred), state] as const; + case "Closed": { + const detail = "ACP session runtime is closed after a failed reload"; + return [ + Effect.fail(new EffectAcpErrors.AcpTransportError({ detail, cause: detail })), + state, + ] as const; + } case "NotStarted": return [ startOnce.pipe( @@ -758,6 +759,59 @@ export const make = ( ); }), ), + reload: promptSerializationSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + const agentCapabilities = started.initializeResult.agentCapabilities; + if ( + agentCapabilities?.loadSession !== true || + agentCapabilities.sessionCapabilities?.close == null + ) { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not support closing and reloading sessions", + method: "session/close", + }); + } + const closePayload = { + sessionId: started.sessionId, + } satisfies EffectAcpSchema.CloseSessionRequest; + yield* runLoggedRequest( + "session/close", + closePayload, + acp.agent.closeSession(closePayload), + ); + const orphanedToolCalls = yield* Ref.getAndSet(toolCallsRef, new Map()); + for (const toolCall of orphanedToolCalls.values()) { + yield* Queue.offer(eventQueue, { + _tag: "ToolCallUpdated", + toolCall: { ...toolCall, status: "failed" }, + rawPayload: { source: "session/reload" }, + }); + } + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Ref.set(startStateRef, { _tag: "Reloading", result: started }); + const sessionSetupResult = yield* loadSession( + started.sessionId, + started.initializeResult, + ).pipe(Effect.onError(() => Ref.set(startStateRef, { _tag: "Closed" }))); + const modeState = parseSessionModeState(sessionSetupResult); + if (modeState !== undefined) { + yield* Ref.set(modeStateRef, modeState); + } + if (sessionSetupResult.configOptions != null) { + yield* Ref.set(configOptionsRef, sessionSetupResult.configOptions); + } + yield* Ref.set(startStateRef, { + _tag: "Started", + result: { + ...started, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult) ?? started.modelConfigId, + }, + }); + }), + ), cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { @@ -771,37 +825,60 @@ export const make = ( }), ), ), - setMode: (modeId) => - Ref.get(modeStateRef).pipe( - Effect.flatMap((modeState) => { - if (modeState?.currentModeId === modeId) { - return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); - } - return setConfigOption("mode", modeId).pipe( - Effect.tap(() => updateCurrentModeId(modeId)), - Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), + cancelAndWait: getStartedState.pipe( + Effect.flatMap((started) => + Effect.gen(function* () { + const activePromptFiber = yield* Ref.get(activePromptFiberRef); + yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore); + if (Option.isNone(activePromptFiber)) return; + const settled = yield* Fiber.await(activePromptFiber.value).pipe( + Effect.timeoutOption("2 seconds"), ); + if (Option.isNone(settled)) { + yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + } }), ), + ), + setMode: (modeId) => + promptSerializationSemaphore.withPermit( + Ref.get(modeStateRef).pipe( + Effect.flatMap((modeState) => { + if (modeState?.currentModeId === modeId) { + return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse); + } + return setConfigOptionUnserialized("mode", modeId).pipe( + Effect.tap(() => updateCurrentModeId(modeId)), + Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse), + ); + }), + ), + ), setConfigOption, setModel: (model) => - getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), - Effect.asVoid, + promptSerializationSemaphore.withPermit( + getStartedState.pipe( + Effect.flatMap((started) => + setConfigOptionUnserialized(started.modelConfigId ?? "model", model), + ), + Effect.asVoid, + ), ), setSessionModel: (modelId) => - getStartedState.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId: started.sessionId, - modelId, - } satisfies EffectAcpSchema.SetSessionModelRequest; - return runLoggedRequest( - "session/set_model", - requestPayload, - acp.agent.setSessionModel(requestPayload), - ); - }), + promptSerializationSemaphore.withPermit( + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + modelId, + } satisfies EffectAcpSchema.SetSessionModelRequest; + return runLoggedRequest( + "session/set_model", + requestPayload, + acp.agent.setSessionModel(requestPayload), + ); + }), + ), ), request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 8d5ba353389d..c47158f42bd6 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -3,11 +3,176 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; import { + isOpenCode2BinaryPath, + openCode2ServiceStateFile, parseAgentListCliOutput, parseModelsCliOutput, + parseOpenCode2AgentsCliOutput, + parseOpenCode2ModelsCliOutput, + parseOpenCode2ServiceRegistration, + parseOpenCodeServerReadyOutput, + redactOpenCodeServerOutput, parseSkillsCliOutput, } from "./opencodeRuntime.ts"; +describe("OpenCode CLI family detection", () => { + it("recognizes opencode2 basenames across supported path styles", () => { + NodeAssert.equal(isOpenCode2BinaryPath("opencode2"), true); + NodeAssert.equal(isOpenCode2BinaryPath("/usr/local/bin/opencode2"), true); + NodeAssert.equal(isOpenCode2BinaryPath("C:\\Tools\\opencode2.exe"), true); + NodeAssert.equal(isOpenCode2BinaryPath("/usr/local/bin/opencode"), false); + NodeAssert.equal(isOpenCode2BinaryPath("opencode2-preview"), false); + }); +}); + +describe("openCode2ServiceStateFile", () => { + it("honors XDG_STATE_HOME", () => { + NodeAssert.equal( + openCode2ServiceStateFile({ XDG_STATE_HOME: "/var/state" }), + "/var/state/opencode/service.json", + ); + }); + + it("falls back to the default state directory", () => { + const path = openCode2ServiceStateFile({}); + NodeAssert.equal(path.endsWith("/.local/state/opencode/service.json"), true); + }); +}); + +describe("parseOpenCode2ServiceRegistration", () => { + it("accepts a healthy registration payload", () => { + NodeAssert.deepEqual( + parseOpenCode2ServiceRegistration({ + id: "abc", + version: "0.0.0-beta-17823", + url: "http://127.0.0.1:49374", + pid: 10028, + password: "secret", + }), + { url: "http://127.0.0.1:49374", serverPassword: "secret" }, + ); + }); + + it("accepts a registration without a password", () => { + NodeAssert.deepEqual(parseOpenCode2ServiceRegistration({ url: "http://127.0.0.1:4096" }), { + url: "http://127.0.0.1:4096", + }); + }); + + it("rejects non-HTTP URLs and malformed payloads", () => { + NodeAssert.equal(parseOpenCode2ServiceRegistration({ url: "unix:///tmp/sock" }), null); + NodeAssert.equal(parseOpenCode2ServiceRegistration("nope"), null); + NodeAssert.equal(parseOpenCode2ServiceRegistration(null), null); + }); +}); + +describe("parseOpenCodeServerReadyOutput", () => { + it("parses stable startup output without a password", () => { + NodeAssert.deepEqual( + parseOpenCodeServerReadyOutput("opencode server listening on http://127.0.0.1:4096\n", false), + { url: "http://127.0.0.1:4096" }, + ); + }); + + it("waits for the OpenCode 2.0 preview password", () => { + NodeAssert.equal( + parseOpenCodeServerReadyOutput("server listening on http://127.0.0.1:4096\n", true), + null, + ); + NodeAssert.deepEqual( + parseOpenCodeServerReadyOutput( + "server listening on http://127.0.0.1:4096\nserver password secret\n", + true, + ), + { url: "http://127.0.0.1:4096", serverPassword: "secret" }, + ); + }); + + it("redacts preview passwords from startup diagnostics", () => { + const output = redactOpenCodeServerOutput( + "server listening on http://127.0.0.1:4096\nserver password secret-value\nfailed\n", + ); + + NodeAssert.equal(output.includes("secret-value"), false); + NodeAssert.equal(output.includes("server password [redacted]"), true); + NodeAssert.equal(output.includes("failed"), true); + }); +}); + +describe("parseOpenCode2ModelsCliOutput", () => { + it("parses one model slug per line", () => { + const result = parseOpenCode2ModelsCliOutput( + "opencode-go/gpt-5.6-luna\nopencode/big-pickle\nopencode/hy3-free\n", + ); + + NodeAssert.deepEqual(result.connected, ["opencode-go", "opencode"]); + NodeAssert.deepEqual(Object.keys(result.providers.get("opencode-go")!.models), [ + "gpt-5.6-luna", + ]); + NodeAssert.deepEqual(Object.keys(result.providers.get("opencode")!.models), [ + "big-pickle", + "hy3-free", + ]); + NodeAssert.equal(result.providers.get("opencode-go")!.name, "OpenCode Go"); + NodeAssert.equal( + result.providers.get("opencode-go")!.models["gpt-5.6-luna"]!.name, + "GPT 5.6 Luna", + ); + }); + + it("ignores non-slug lines", () => { + const result = parseOpenCode2ModelsCliOutput( + "loading models\ninvalid\n/provider\nprovider/\nprovider/model extra\n", + ); + NodeAssert.equal(result.providers.size, 0); + }); +}); + +describe("parseOpenCode2AgentsCliOutput", () => { + it("maps debug agents JSON to SDK agent inventory", () => { + const result = parseOpenCode2AgentsCliOutput( + JSON.stringify([ + { + id: "build", + name: "Build", + description: "The default agent.", + mode: "primary", + hidden: false, + request: { settings: { temperature: 0.2 } }, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "read", resource: "*.env", effect: "ask" }, + ], + }, + { + id: "compaction", + name: "Compaction", + mode: "primary", + hidden: true, + permissions: [], + }, + ]), + ); + + NodeAssert.equal(result.length, 2); + NodeAssert.equal(result[0]!.name, "build"); + NodeAssert.equal(result[0]!.mode, "primary"); + NodeAssert.equal(result[0]!.description, "The default agent."); + NodeAssert.deepEqual(result[0]!.options, { temperature: 0.2 }); + NodeAssert.deepEqual(result[0]!.permission[1], { + permission: "read", + pattern: "*.env", + action: "ask", + }); + NodeAssert.equal(result[1]!.hidden, true); + }); + + it("returns an empty inventory for invalid debug output", () => { + NodeAssert.deepEqual(parseOpenCode2AgentsCliOutput("not json"), []); + NodeAssert.deepEqual(parseOpenCode2AgentsCliOutput("{}"), []); + }); +}); + describe("parseModelsCliOutput", () => { it("parses a single model from a single provider", () => { const stdout = [ diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..7d4f3a263a8d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,6 +1,12 @@ +import * as NodeOS from "node:os"; import * as NodeURL from "node:url"; -import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; +import type { + ChatAttachment, + OpenCodeSettings, + ProviderApprovalDecision, + RuntimeMode, +} from "@t3tools/contracts"; import { createOpencodeClient, type Agent, @@ -27,7 +33,9 @@ import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as FileSystem from "effect/FileSystem"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; @@ -35,6 +43,7 @@ import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export function resolveOpenCodeConfigContent( @@ -49,15 +58,69 @@ export function resolveOpenCodeConfigContent( } const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const OPENCODE2_SERVER_READY_PREFIX = "server listening"; +const OPENCODE2_SERVER_PASSWORD_PREFIX = "server password "; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; + +/** + * Binary name of the OpenCode 2 preview. When the user has not pinned a + * `binaryPath` (left at the `"opencode"` default), the driver upgrades to this + * binary whenever it resolves on PATH — no settings change required. + */ +export const OPENCODE2_DEFAULT_BINARY = "opencode2"; + +export function isOpenCode2BinaryPath(binaryPath: string): boolean { + return /(?:^|[\\/])opencode2(?:\.exe)?$/i.test(binaryPath.trim()); +} + +/** + * Where the OpenCode 2 background service registers itself while running + * (`~/.local/state/opencode/service.json` by default, `$XDG_STATE_HOME` + * aware). Reading this file is how we detect an already-running server + * instead of spawning our own. + */ +export function openCode2ServiceStateFile( + env: Readonly> = process.env, +): string { + // Unix-only: discovery skips Windows entirely (see + // discoverRegisteredOpenCode2Server), so forward slashes are correct. + const stateHome = env.XDG_STATE_HOME?.trim() || `${NodeOS.homedir()}/.local/state`; + return `${stateHome}/opencode/service.json`; +} + +export interface OpenCode2ServiceRegistration { + readonly url: string; + readonly serverPassword?: string; +} + +/** + * Validate a parsed `service.json` payload. Returns null for anything that + * isn't a usable HTTP endpoint so a corrupt or future-shaped registration + * degrades to "no running server" rather than a failed spawn. + * + * @internal + */ +export function parseOpenCode2ServiceRegistration( + raw: unknown, +): OpenCode2ServiceRegistration | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const url = typeof record.url === "string" ? record.url.trim() : ""; + if (!/^https?:\/\//i.test(url)) return null; + const password = typeof record.password === "string" ? record.password.trim() : ""; + return { url, ...(password ? { serverPassword: password } : {}) }; +} + export interface OpenCodeServerProcess { readonly url: string; + readonly serverPassword?: string; readonly exitCode: Effect.Effect; } export interface OpenCodeServerConnection { readonly url: string; + readonly serverPassword?: string; readonly exitCode: Effect.Effect | null; readonly external: boolean; } @@ -77,6 +140,17 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { return Exit.isSuccess(result) ? result.value : undefined; } +const OPENCODE_BASIC_AUTH_USERNAME = "opencode"; + +/** + * OpenCode servers authenticate with HTTP Basic using a fixed username and a + * per-server password (spawn output for scoped servers, `service.json` for + * adopted background services). + */ +export function openCodeBasicAuthHeader(password: string): string { + return `Basic ${Buffer.from(`${OPENCODE_BASIC_AUTH_USERNAME}:${password}`, "utf8").toString("base64")}`; +} + export function openCodeRuntimeErrorDetail(cause: unknown): string { if (OpenCodeRuntimeError.is(cause)) return cause.detail; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); @@ -153,17 +227,33 @@ export interface OpenCodeRuntimeShape { }) => Effect.Effect; /** * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. + * `serverUrl` is provided — no lifetime is attached to the caller's scope), + * an already-running OpenCode 2 background service adopted from its + * registration file (also external — we don't own its lifetime), or a + * freshly spawned local server whose lifetime is bound to the caller's + * scope. Adoption is attempted for `opencode2` binaries only and is skipped + * when an explicit `serverPassword` is set. */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly serverUrl?: string | null; + readonly serverPassword?: string | null; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; }) => Effect.Effect; + /** + * Upgrade the default OpenCode binary to the v2 preview when one resolves: + * a `binaryPath` left at the `"opencode"` default (or empty) becomes + * `"opencode2"` when ` --version` succeeds, so users with the + * preview installed get it without touching settings. Any explicit path is + * returned unchanged. + */ + readonly resolveDefaultBinaryPath: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; readonly runOpenCodeCommand: (input: { readonly binaryPath: string; readonly args: ReadonlyArray; @@ -185,19 +275,59 @@ export interface OpenCodeRuntimeShape { }) => Effect.Effect; } -function parseServerUrlFromOutput(output: string): string | null { +export function parseOpenCodeServerReadyOutput( + output: string, + isOpenCode2: boolean, +): { readonly url: string; readonly serverPassword?: string } | null { + let url: string | undefined; + let serverPassword: string | undefined; for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { - continue; + const readyPrefix = isOpenCode2 ? OPENCODE2_SERVER_READY_PREFIX : OPENCODE_SERVER_READY_PREFIX; + if (line.startsWith(readyPrefix)) { + url = line.match(/on\s+(https?:\/\/[^\s]+)/)?.[1]; + } + if (isOpenCode2 && line.startsWith(OPENCODE2_SERVER_PASSWORD_PREFIX)) { + serverPassword = line.slice(OPENCODE2_SERVER_PASSWORD_PREFIX.length).trim() || undefined; } - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - return match?.[1] ?? null; } - return null; + if (!url || (isOpenCode2 && !serverPassword)) return null; + return { url, ...(serverPassword ? { serverPassword } : {}) }; +} + +/** @internal */ +export function redactOpenCodeServerOutput(output: string): string { + return output + .split("\n") + .map((line) => + line.startsWith(OPENCODE2_SERVER_PASSWORD_PREFIX) + ? `${OPENCODE2_SERVER_PASSWORD_PREFIX}[redacted]` + : line, + ) + .join("\n"); } const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; +const OPENCODE_SLUG_LABELS: Readonly> = { + deepseek: "DeepSeek", + glm: "GLM", + gpt: "GPT", + minimax: "MiniMax", + mimo: "MiMo", + opencode: "OpenCode", +}; + +export function formatOpenCodeSlugLabel(value: string): string { + return value + .split(/[-_/]+/) + .filter(Boolean) + .map( + (segment) => + OPENCODE_SLUG_LABELS[segment.toLowerCase()] ?? + segment.charAt(0).toUpperCase() + segment.slice(1), + ) + .join(" "); +} // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent @@ -266,6 +396,64 @@ export function parseModelsCliOutput(stdout: string): { return { providers, connected: [...providers.keys()] }; } +function openCode2ModelFromSlug(slug: ParsedOpenCodeModelSlug): Model { + return { + id: slug.modelID, + providerID: slug.providerID, + api: { id: slug.modelID, url: "", npm: "" }, + name: formatOpenCodeSlugLabel(slug.modelID), + capabilities: { + temperature: false, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }; +} + +/** @internal */ +export function parseOpenCode2ModelsCliOutput(stdout: string): { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; +} { + const providers = new Map< + string, + { id: string; name: string; models: { [key: string]: Model } } + >(); + + for (const line of stdout.split(/\r?\n/)) { + const match = SLUG_LINE_RE.exec(line); + const slug = parseOpenCodeModelSlug(match?.[1]); + if (!slug) { + continue; + } + let provider = providers.get(slug.providerID); + if (!provider) { + provider = { + id: slug.providerID, + name: formatOpenCodeSlugLabel(slug.providerID), + models: {}, + }; + providers.set(slug.providerID, provider); + } + provider.models[slug.modelID] = openCode2ModelFromSlug(slug); + } + + return { providers, connected: [...providers.keys()] }; +} + /** @internal */ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const agents: Array = []; @@ -309,6 +497,74 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { return agents; } +/** @internal */ +export function parseOpenCode2AgentsCliOutput(stdout: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + + const agents: Array = []; + for (const value of parsed) { + if (!value || typeof value !== "object") { + continue; + } + const record = value as Record; + const name = typeof record.id === "string" ? record.id.trim() : ""; + const mode = record.mode; + if (name.length === 0 || (mode !== "primary" && mode !== "subagent" && mode !== "all")) { + continue; + } + + const permission = Array.isArray(record.permissions) + ? record.permissions.flatMap((candidate) => { + if (!candidate || typeof candidate !== "object") { + return []; + } + const rule = candidate as Record; + if ( + typeof rule.action !== "string" || + typeof rule.resource !== "string" || + (rule.effect !== "allow" && rule.effect !== "deny" && rule.effect !== "ask") + ) { + return []; + } + return [ + { + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + } satisfies PermissionRuleset[number], + ]; + }) + : []; + const request = + record.request && typeof record.request === "object" + ? (record.request as Record) + : undefined; + const options = + request?.settings && typeof request.settings === "object" + ? (request.settings as Record) + : {}; + + agents.push({ + name, + mode, + hidden: record.hidden === true, + permission, + options, + ...(typeof record.description === "string" ? { description: record.description } : {}), + }); + } + + return agents; +} + /** @internal */ export function parseSkillsCliOutput(stdout: string): ReadonlyArray { const result = decodeOpenCodeSkillsCliOutputExit(stdout); @@ -434,6 +690,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; const hostPlatform = yield* HostProcessPlatform; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => resolveSpawnCommand(command, args, env ? { env } : {}); @@ -495,6 +753,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const isOpenCode2 = isOpenCode2BinaryPath(input.binaryPath); const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); @@ -550,12 +809,15 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const stdoutRef = yield* Ref.make(""); const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); + const readyDeferred = yield* Deferred.make< + { readonly url: string; readonly serverPassword?: string }, + OpenCodeRuntimeError + >(); const setReadyFromStdoutChunk = (chunk: string) => Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( Effect.flatMap((nextStdout) => { - const parsed = parseServerUrlFromOutput(nextStdout); + const parsed = parseOpenCodeServerReadyOutput(nextStdout, isOpenCode2); return parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void; @@ -580,6 +842,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.gen(function* () { const stdout = yield* Ref.get(stdoutRef); const stderr = yield* Ref.get(stderrRef); + const diagnosticStdout = redactOpenCodeServerOutput(stdout); + const diagnosticStderr = redactOpenCodeServerOutput(stderr); const exitCode = Number(code); yield* Deferred.fail( readyDeferred, @@ -587,12 +851,12 @@ const makeOpenCodeRuntime = Effect.gen(function* () { operation: "startOpenCodeServerProcess", detail: [ `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + diagnosticStdout.trim() ? `stdout:\n${diagnosticStdout.trim()}` : null, + diagnosticStderr.trim() ? `stderr:\n${diagnosticStderr.trim()}` : null, ] .filter(Boolean) .join("\n\n"), - cause: { exitCode, stdout, stderr }, + cause: { exitCode, stdout: diagnosticStdout, stderr: diagnosticStderr }, }), ).pipe(Effect.ignore); }), @@ -631,7 +895,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } return { - url: readyOption.value, + ...readyOption.value, exitCode: child.exitCode.pipe( Effect.map(Number), Effect.orElseSucceed(() => 0), @@ -639,6 +903,68 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } satisfies OpenCodeServerProcess; }); + /** + * Look for an already-running OpenCode 2 background service and health-check + * it before adopting. Any failure — missing registration file, unparsable + * JSON, dead endpoint, timeout — resolves to null so the caller falls + * through to spawning its own server. Never starts anything. + */ + const discoverRegisteredOpenCode2Server = (input: { + readonly environment?: NodeJS.ProcessEnv; + }): Effect.Effect => + Effect.gen(function* () { + // The service registration is documented for unix state directories; + // don't guess a Windows location. + if (hostPlatform === "win32") return null; + const raw = yield* fileSystem + .readFileString(openCode2ServiceStateFile(input.environment)) + .pipe(Effect.option); + if (Option.isNone(raw)) return null; + const parsedExit = decodeUnknownJsonStringExit(raw.value); + if (!Exit.isSuccess(parsedExit)) return null; + const registration = parseOpenCode2ServiceRegistration(parsedExit.value); + if (!registration) return null; + + const baseUrl = registration.url.replace(/\/+$/, ""); + let request = HttpClientRequest.get(`${baseUrl}/api/health`); + if (registration.serverPassword) { + request = HttpClientRequest.setHeader( + request, + "authorization", + openCodeBasicAuthHeader(registration.serverPassword), + ); + } + // A stale registration (crashed daemon, rebooted machine) must fall + // through to spawning our own server, so any transport failure or + // timeout resolves to "not adopted". + const healthy = yield* httpClient.execute(request).pipe( + Effect.timeout("3 seconds"), + Effect.map((response) => response.status === 200), + Effect.orElseSucceed(() => false), + ); + return healthy ? registration : null; + }); + + const resolveDefaultBinaryPath: OpenCodeRuntimeShape["resolveDefaultBinaryPath"] = (input) => { + const trimmed = input.binaryPath.trim(); + // Only upgrade when no explicit binary was configured ("opencode" is the + // settings default; empty behaves the same way). + if (trimmed.length > 0 && trimmed.toLowerCase() !== "opencode") { + return Effect.succeed(input.binaryPath); + } + // An empty setting falls back to the stable binary name rather than + // propagating a spawnable-but-empty path. + const fallback = trimmed.length > 0 ? input.binaryPath : "opencode"; + return runOpenCodeCommand({ + binaryPath: OPENCODE2_DEFAULT_BINARY, + args: ["--version"], + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.map((result) => (result.code === 0 ? OPENCODE2_DEFAULT_BINARY : fallback)), + Effect.orElseSucceed(() => fallback), + ); + }; + const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { @@ -650,19 +976,42 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); } - return startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - ...(input.environment !== undefined ? { environment: input.environment } : {}), - ...(input.port !== undefined ? { port: input.port } : {}), - ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }).pipe( - Effect.map((server) => ({ + // Adopt the user's running OpenCode 2 background service when one is up + // (mirrors how v2's own clients connect). An explicit `serverPassword` + // means the user is targeting their own authenticated server, not the + // shared registration, so discovery stays out of the way. Adopted + // services are external: we never kill them. + const adoptSharedService = + !input.serverPassword && isOpenCode2BinaryPath(input.binaryPath) + ? discoverRegisteredOpenCode2Server({ + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }) + : Effect.succeed(null); + + return Effect.gen(function* () { + const adopted = yield* adoptSharedService; + if (adopted) { + return { + url: adopted.url, + ...(adopted.serverPassword ? { serverPassword: adopted.serverPassword } : {}), + exitCode: null, + external: true, + } satisfies OpenCodeServerConnection; + } + const server = yield* startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + return { url: server.url, + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), exitCode: server.exitCode, external: false, - })), - ); + } satisfies OpenCodeServerConnection; + }); }; const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => @@ -715,25 +1064,30 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.gen(function* () { const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); const commandContext = { cwd: input.cwd, ...env }; + const isOpenCode2 = isOpenCode2BinaryPath(input.binaryPath); const runModelsCli = () => runOpenCodeCommand({ binaryPath: input.binaryPath, - args: ["models", "--verbose"], + args: isOpenCode2 ? ["models"] : ["models", "--verbose"], ...commandContext, }).pipe(Effect.exit); const runAgentsCli = () => runOpenCodeCommand({ binaryPath: input.binaryPath, - args: ["agent", "list"], + args: isOpenCode2 ? ["debug", "agents"] : ["agent", "list"], ...commandContext, }).pipe(Effect.exit); const runSkillsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - ...commandContext, - }).pipe(Effect.exit); + isOpenCode2 + ? Effect.succeed( + Exit.succeed({ stdout: "", stderr: "", code: 0 } satisfies OpenCodeCommandResult), + ) + : runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + ...commandContext, + }).pipe(Effect.exit); // First attempt — run all inventory commands in parallel. const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( @@ -778,7 +1132,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); } - const parsed = parseModelsCliOutput(modelsResult.value.stdout); + const parsed = isOpenCode2 + ? parseOpenCode2ModelsCliOutput(modelsResult.value.stdout) + : parseModelsCliOutput(modelsResult.value.stdout); const connected = [...parsed.connected]; const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( (provider) => ({ @@ -795,7 +1151,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { // for an authoritative model inventory, so either may degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { - agents = parseAgentListCliOutput(agentsResult.value.stdout); + agents = isOpenCode2 + ? parseOpenCode2AgentsCliOutput(agentsResult.value.stdout) + : parseAgentListCliOutput(agentsResult.value.stdout); } let skills: ReadonlyArray = []; if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { @@ -812,6 +1170,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return { startOpenCodeServerProcess, connectToOpenCodeServer, + resolveDefaultBinaryPath, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, @@ -825,4 +1184,7 @@ export class OpenCodeRuntime extends Context.Service Effect.succeed(binaryPath), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ @@ -160,6 +162,9 @@ const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", }); +const OPENCODE2_TEXT_GENERATION_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "opencode2", +}); const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", @@ -187,6 +192,18 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("authenticates to a local OpenCode 2.0 preview server", () => + withOpenCodeTextGeneration(OPENCODE2_TEXT_GENERATION_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:generated-preview-password")}`, + ]); + }), + ), + ); + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..fd5189d4d8cb 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -381,12 +381,13 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { + function* (server: Pick) { const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } + ...(server.serverPassword || + (openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword) + ? { serverPassword: server.serverPassword ?? openCodeSettings.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a0518bdabef2..066cd40d6f3a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -562,7 +562,11 @@ export interface ChatComposerProps { isLastQuestion: boolean; canAdvance: boolean; customAnswer: string; - activeQuestion: { id: string; multiSelect?: boolean | undefined } | null; + activeQuestion: { + id: string; + multiSelect?: boolean | undefined; + options: ReadonlyArray<{ label: string; description: string }>; + } | null; } | null; activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; @@ -1197,6 +1201,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; + const pendingCustomAnswerLabel = + activePendingProgress?.activeQuestion?.options.length === 0 + ? "Type your answer" + : "Write custom answer"; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const composerFooterActionLayoutKey = useMemo(() => { @@ -2070,7 +2078,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // unique image into the overflow list for nothing. const existingDedupKeys = new Set( composerImagesRef.current.map( - (image) => `${image.mimeType}${image.sizeBytes}${image.name}`, + (image) => `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`, ), ); const capacity = Math.max( @@ -2081,7 +2089,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (attachment) => !existingIds.has(attachment.id) && !existingDedupKeys.has( - `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`, + `${attachment.mimeType}\u0000${attachment.sizeBytes}\u0000${attachment.name}`, ), ); // Anything past the attachment limit cannot be restored. The entry is @@ -2173,7 +2181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // the composer has been cleared the user can type something genuinely // new (or switch threads) while encoding continues, and that deserves its // own entry. - const snapshotKey = `${String(composerDraftTarget)}${prompt}${images + const snapshotKey = `${String(composerDraftTarget)}\u0000${prompt}\u0000${images .map((image) => image.id) .join(",")}`; if (stashInFlightRef.current.has(snapshotKey)) return; @@ -2917,9 +2925,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposer} - aria-label="Write custom answer" + aria-label={pendingCustomAnswerLabel} > - {activePendingProgress?.customAnswer || "Write custom answer"} + {activePendingProgress?.customAnswer || pendingCustomAnswerLabel} {inlineTasksBadge} {inlineStashBadge} @@ -3231,7 +3239,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" + ? activePendingProgress.activeQuestion?.options.length === 0 + ? "Type your answer" + : "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx index 817182190b79..e37bcb7a2d6b 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -22,10 +22,10 @@ const prompt: PendingUserInput = { ], }; -function renderPanel() { +function renderPanel(value = prompt) { return renderToStaticMarkup( { expect(markup).toContain("Incremental"); expect(markup).toContain("Big bang"); }); + + it("points free-form questions to the composer without option copy", () => { + const markup = renderPanel({ + ...prompt, + questions: [{ ...prompt.questions[0]!, options: [], multiSelect: false }], + }); + + expect(markup).toContain("Type your answer in the composer below."); + expect(markup).not.toContain("Select one or more options."); + expect(markup).not.toContain("Incremental"); + }); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c121110bb3f0..a5f8679ce4c1 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -164,6 +164,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( } const customAnswerActive = progress.customAnswer.trim().length > 0; + const hasOptions = activeQuestion.options.length > 0; + const disclosureTitle = hasOptions ? "the question and its options" : "the question"; return ( @@ -220,62 +220,70 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(

{activeQuestion.question}

- {activeQuestion.multiSelect ? ( + {hasOptions && activeQuestion.multiSelect ? (

Select one or more options.

+ ) : !hasOptions ? ( +

+ Type your answer in the composer below. +

) : null} -
- {activeQuestion.options.map((option, index) => { - const isOptimisticallySelected = - optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; - const isSelected = - isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); - const shortcutKey = index < 9 ? index + 1 : null; - const className = cn( - "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25", - isSelected - ? "bg-muted/55 text-foreground" - : "bg-transparent text-foreground/85 hover:bg-muted/30", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", - ); - const content = ( - <> -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} + {hasOptions ? ( +
+ {activeQuestion.options.map((option, index) => { + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = + isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + const shortcutKey = index < 9 ? index + 1 : null; + const className = cn( + "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25", + isSelected + ? "bg-muted/55 text-foreground" + : "bg-transparent text-foreground/85 hover:bg-muted/30", + isResponding && "opacity-50 cursor-not-allowed", + !isResponding && "cursor-pointer", + ); + const content = ( + <> +
+ {option.label} + {option.description && option.description !== option.label ? ( + + {option.description} + + ) : null} +
+ {isSelected ? ( + + ) : shortcutKey !== null ? ( + + {shortcutKey} + ) : null} -
- {isSelected ? ( - - ) : shortcutKey !== null ? ( - - {shortcutKey} - - ) : null} - - ); - return ( - - ); - })} -
+ + ); + return ( + + ); + })} +
+ ) : null}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e94712d3e4da..735f3c5b116c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -217,6 +217,31 @@ describe("derivePendingApprovals", () => { }); describe("derivePendingUserInputs", () => { + it("keeps free-form questions with no preset options", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-free-form", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-free-form", + questions: [ + { + id: "name", + header: "Name", + question: "What should this be called?", + options: [], + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)[0]?.questions[0]?.options).toEqual([]); + }); + it("tracks open structured prompts and removes resolved ones", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..cef23724fb8d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -490,9 +490,6 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { - return null; - } return { id: question.id, header: question.header, diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..a390607b7240 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -71,6 +71,11 @@ T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login` Run the login command on the machine running the T3 Code server, not on the device you browse from. +The OpenCode 2.0 preview installs a separate `opencode2` executable. T3 Code detects it +automatically and talks to OpenCode 2's native server: if a background service is already +running, it is adopted; otherwise T3 Code starts one for the session. No settings change is +needed, and an explicit **Binary path** still wins. + ### Binary Discovery Each provider CLI must be on the server's `PATH`, or have an explicit binary path set in