From ea275d5d18f758ecb4416ee41e61ee7cadc6a29c Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 09:16:50 -0600 Subject: [PATCH 01/11] feat(contracts): add OpenHandsSettings provider schema Mirrors GrokSettings: opt-in `enabled` flag, binary path override, and custom model list. Also makes AcpSessionRuntime's `authMethodId` optional so agents that authenticate out of band are not forced through `authenticate`; OpenHands only advertises an interactive cloud OAuth device flow, which would start a login on every session. Co-authored-by: openhands --- .../src/provider/acp/AcpSessionRuntime.ts | 27 ++++++++++----- packages/contracts/src/settings.ts | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..9b77f5d33428 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -92,7 +92,13 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * Auth method to send before session setup. Omit for agents that authenticate + * out of band: `authenticate` is not optional in ACP, so an agent that only + * advertises an interactive method (OpenHands' cloud OAuth device flow) would + * otherwise either reject the call or start a login on every session. + */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; @@ -699,15 +705,18 @@ export const make = ( const startOnce = Effect.gen(function* () { const initializeResult = yield* sendInitialize; - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + const authMethodId = options.authMethodId; + if (authMethodId !== undefined) { + const authenticatePayload = { + methodId: authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..04c94b6b9acf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -706,6 +706,32 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const OpenHandsSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): the binding is not yet + // stable enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("openhands").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the OpenHands CLI binary.", + providerSettingsForm: { placeholder: "openhands", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type OpenHandsSettings = typeof OpenHandsSettings.Type; + /** * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in * in the browser. The API key and Agent Platform methods take credentials from @@ -1042,6 +1068,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + openhands: OpenHandsSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1198,6 +1225,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const OpenHandsSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + const AntigravitySettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), authMethod: Schema.optionalKey(AntigravityAuthMethod), @@ -1272,6 +1305,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + openhands: Schema.optionalKey(OpenHandsSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), From 78fe64fe5bea8831c7439d2e28297e43960bfdae Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 18:19:25 -0600 Subject: [PATCH 02/11] feat(provider): add OpenHands ACP spawn support Spawns `openhands acp` (workaround for the broken openhands-acp console script in v1.16.0, see module doc-comment) and translates CLI runtime modes to ACP session modes. Co-authored-by: openhands --- .../provider/acp/OpenHandsAcpSupport.test.ts | 90 +++++++++++ .../src/provider/acp/OpenHandsAcpSupport.ts | 143 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/OpenHandsAcpSupport.ts diff --git a/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts b/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts new file mode 100644 index 000000000000..4287c1930f94 --- /dev/null +++ b/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + OPENHANDS_ALWAYS_APPROVE_MODE_ID, + OPENHANDS_ALWAYS_ASK_MODE_ID, + OPENHANDS_LLM_APPROVE_MODE_ID, + buildOpenHandsAcpSpawnInput, + openHandsAcpModeId, + openHandsAcpSpawnArgs, + resolveOpenHandsAcpBaseModelId, +} from "./OpenHandsAcpSupport.ts"; + +describe("resolveOpenHandsAcpBaseModelId", () => { + it("falls back to the built-in slug when no custom model id is set", () => { + expect(resolveOpenHandsAcpBaseModelId(undefined)).toBe("openhands-default"); + expect(resolveOpenHandsAcpBaseModelId(null)).toBe("openhands-default"); + expect(resolveOpenHandsAcpBaseModelId(" ")).toBe("openhands-default"); + }); + + it("trims and keeps an explicit model id", () => { + expect(resolveOpenHandsAcpBaseModelId(" openhands-custom-model ")).toBe( + "openhands-custom-model", + ); + }); +}); + +describe("openHandsAcpModeId", () => { + it("defaults to always-ask when no runtime mode is set", () => { + expect(openHandsAcpModeId(undefined)).toBe(OPENHANDS_ALWAYS_ASK_MODE_ID); + }); + + it("maps approval-required to always-ask", () => { + expect(openHandsAcpModeId("approval-required")).toBe(OPENHANDS_ALWAYS_ASK_MODE_ID); + }); + + it("maps auto-accept-edits and auto onto the LLM security analyzer", () => { + expect(openHandsAcpModeId("auto-accept-edits")).toBe(OPENHANDS_LLM_APPROVE_MODE_ID); + expect(openHandsAcpModeId("auto")).toBe(OPENHANDS_LLM_APPROVE_MODE_ID); + }); + + it("maps full-access to always-approve", () => { + expect(openHandsAcpModeId("full-access")).toBe(OPENHANDS_ALWAYS_APPROVE_MODE_ID); + }); +}); + +describe("openHandsAcpSpawnArgs", () => { + it("has no flag for the always-ask default", () => { + expect(openHandsAcpSpawnArgs()).toEqual(["acp"]); + expect(openHandsAcpSpawnArgs("approval-required")).toEqual(["acp"]); + }); + + it("passes --llm-approve for auto-accept-edits and auto", () => { + expect(openHandsAcpSpawnArgs("auto-accept-edits")).toEqual(["acp", "--llm-approve"]); + expect(openHandsAcpSpawnArgs("auto")).toEqual(["acp", "--llm-approve"]); + }); + + it("passes --always-approve for full-access", () => { + expect(openHandsAcpSpawnArgs("full-access")).toEqual(["acp", "--always-approve"]); + }); +}); + +describe("buildOpenHandsAcpSpawnInput", () => { + it("defaults to the `openhands` binary and suppresses the startup banner", () => { + const spawn = buildOpenHandsAcpSpawnInput(undefined, "/tmp/project"); + expect(spawn.command).toBe("openhands"); + expect(spawn.args).toEqual(["acp"]); + expect(spawn.cwd).toBe("/tmp/project"); + expect(spawn.env?.OPENHANDS_SUPPRESS_BANNER).toBe("1"); + }); + + it("honors a configured binary path override", () => { + const spawn = buildOpenHandsAcpSpawnInput( + { binaryPath: "/usr/local/bin/openhands" }, + "/tmp/project", + ); + expect(spawn.command).toBe("/usr/local/bin/openhands"); + }); + + it("merges the caller's environment and preserves the runtime mode flag", () => { + const spawn = buildOpenHandsAcpSpawnInput( + undefined, + "/tmp/project", + { FOO: "bar" }, + "full-access", + ); + expect(spawn.args).toEqual(["acp", "--always-approve"]); + expect(spawn.env?.FOO).toBe("bar"); + expect(spawn.env?.OPENHANDS_SUPPRESS_BANNER).toBe("1"); + }); +}); diff --git a/apps/server/src/provider/acp/OpenHandsAcpSupport.ts b/apps/server/src/provider/acp/OpenHandsAcpSupport.ts new file mode 100644 index 000000000000..ce16b74d4a7d --- /dev/null +++ b/apps/server/src/provider/acp/OpenHandsAcpSupport.ts @@ -0,0 +1,143 @@ +/** + * OpenHandsAcpSupport — spawn and runtime wiring for the OpenHands CLI over ACP. + * + * The CLI ships an `openhands-acp` console script, but in 1.16.0 its generated + * entry point targets `openhands_cli.acp:main` while the wheel only contains + * `openhands_cli.acp_impl`, so the script raises `ModuleNotFoundError` on every + * invocation. The `openhands acp` subcommand runs the same server and is what we + * spawn; switch back to the dedicated binary only once it resolves upstream. + * + * @module OpenHandsAcpSupport + */ +import { + type OpenHandsSettings, + OPENHANDS_DEFAULT_MODEL, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +/** Keeps the startup banner off the wire. It goes to stderr, but stderr is logged. */ +const OPENHANDS_SUPPRESS_BANNER_ENV = "OPENHANDS_SUPPRESS_BANNER"; + +/** + * Confirmation modes the agent advertises in `session/new`. They double as the + * mode ids accepted by `session/set_mode`, so switching a live session uses the + * same vocabulary as the launch flags. + */ +export const OPENHANDS_ALWAYS_ASK_MODE_ID = "always-ask"; +export const OPENHANDS_LLM_APPROVE_MODE_ID = "llm-approve"; +export const OPENHANDS_ALWAYS_APPROVE_MODE_ID = "always-approve"; + +type OpenHandsAcpRuntimeSettings = Pick; + +export interface OpenHandsAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly openHandsSettings: OpenHandsAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +/** + * OpenHands has three confirmation modes and no edit-only tier, so + * `auto-accept-edits` and `auto` both land on the LLM security analyzer: it is + * the only setting that drops routine prompts while still stopping on the + * actions OpenHands rates high risk. + */ +export function openHandsAcpModeId(runtimeMode: RuntimeMode | undefined): string { + switch (runtimeMode) { + case "auto-accept-edits": + case "auto": + return OPENHANDS_LLM_APPROVE_MODE_ID; + case "full-access": + return OPENHANDS_ALWAYS_APPROVE_MODE_ID; + case "approval-required": + default: + return OPENHANDS_ALWAYS_ASK_MODE_ID; + } +} + +/** + * `acp` is an argparse subcommand, so its flags must follow it. Always-ask is the + * CLI default and has no flag of its own. + */ +export function openHandsAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (openHandsAcpModeId(runtimeMode)) { + case OPENHANDS_LLM_APPROVE_MODE_ID: + return ["acp", "--llm-approve"]; + case OPENHANDS_ALWAYS_APPROVE_MODE_ID: + return ["acp", "--always-approve"]; + default: + return ["acp"]; + } +} + +export function buildOpenHandsAcpSpawnInput( + openHandsSettings: OpenHandsAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: openHandsSettings?.binaryPath || "openhands", + args: [...openHandsAcpSpawnArgs(runtimeMode)], + cwd, + env: { + ...environment, + [OPENHANDS_SUPPRESS_BANNER_ENV]: "1", + }, + }; +} + +/** + * Builds the session runtime. No `authMethodId` is sent: OpenHands advertises only + * its cloud OAuth device flow, and a local install is already authenticated through + * `~/.openhands`, so calling `authenticate` would start a browser login per session. + */ +export const makeOpenHandsAcpRuntime = ( + input: OpenHandsAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildOpenHandsAcpSpawnInput( + input.openHandsSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +/** + * T3's built-in OpenHands slug. OpenHands resolves its LLM from `~/.openhands` and + * its ACP session carries no model state, so the slug only ever means "whatever the + * CLI is configured with" and is never sent over the wire. + */ +export const OPENHANDS_DEFAULT_MODEL_SLUG = OPENHANDS_DEFAULT_MODEL; + +export function resolveOpenHandsAcpBaseModelId(model: string | null | undefined): string { + return model?.trim() || OPENHANDS_DEFAULT_MODEL_SLUG; +} From c76b5cc4e613e8a4386ff818afcb1b43dd171712 Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 18:20:46 -0600 Subject: [PATCH 03/11] feat(contracts): register OpenHands driver kind and default model Adds OPENHANDS_DRIVER_KIND/OPENHANDS_DEFAULT_MODEL and wires them into DEFAULT_MODEL_BY_PROVIDER / DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, mirroring Antigravity's 'keep the session's current model' sentinel since OpenHands has no model catalog to select against over ACP. Co-authored-by: openhands --- packages/contracts/src/model.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index bce1a766bc9b..56f12a7cbd0d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,6 +148,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OPENHANDS_DRIVER_KIND = ProviderDriverKind.make("openhands"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -163,6 +164,12 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; +/** + * Keep the model the OpenHands CLI is configured with. Never send this ID to ACP: + * OpenHands resolves its LLM from `~/.openhands`, and its ACP session advertises no + * model state, so T3 has nothing to select against. + */ +export const OPENHANDS_DEFAULT_MODEL = "openhands-default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { @@ -172,6 +179,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial Date: Tue, 8 Sep 2026 18:20:51 -0600 Subject: [PATCH 04/11] feat(provider): add the OpenHands ACP adapter Runs the OpenHands CLI's ACP session, translating its runtime modes, permission requests, and tool-call/plan updates into T3's orchestration events, following GrokAdapter/CursorAdapter's shape. Co-authored-by: openhands --- .../provider/Layers/OpenHandsAdapter.test.ts | 258 +++++ .../src/provider/Layers/OpenHandsAdapter.ts | 960 ++++++++++++++++++ .../src/provider/Services/OpenHandsAdapter.ts | 17 + 3 files changed, 1235 insertions(+) create mode 100644 apps/server/src/provider/Layers/OpenHandsAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/OpenHandsAdapter.ts create mode 100644 apps/server/src/provider/Services/OpenHandsAdapter.ts diff --git a/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts b/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts new file mode 100644 index 000000000000..47003ca18082 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts @@ -0,0 +1,258 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + OpenHandsSettings, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import type { AcpSessionModeState } from "../acp/AcpRuntimeModel.ts"; +import { ServerConfig } from "../../config.ts"; +import { + makeOpenHandsAdapter, + parseOpenHandsResume, + resolveRequestedModeId, + selectPermissionOptionId, +} from "./OpenHandsAdapter.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +async function makeMockOpenHandsWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "openhands-acp-mock-")); + return writeFakeCli({ + directory: dir, + name: "fake-openhands", + env: extraEnv ?? {}, + // Real spawns pass `acp` plus a mode flag (`--llm-approve`, `--always-approve`); + // only the subcommand is asserted since the flag varies with runtimeMode. + source: execScriptSource({ scriptPath: mockAgentPath, expectedArgs: ["acp"] }), + }); +} + +const openHandsAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-openhands-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = ( + binaryPath: string, + options?: Parameters[1], +) => makeOpenHandsAdapter(decodeOpenHandsSettings({ binaryPath }), options).pipe(Effect.orDie); + +it("accepts a resume cursor only when its schema version and sessionId match", () => { + assert.deepEqual(parseOpenHandsResume({ schemaVersion: 1, sessionId: "abc" }), { + sessionId: "abc", + }); + assert.isUndefined(parseOpenHandsResume(undefined)); + assert.isUndefined(parseOpenHandsResume(null)); + assert.isUndefined(parseOpenHandsResume("abc")); + assert.isUndefined(parseOpenHandsResume({ schemaVersion: 2, sessionId: "abc" })); + assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1, sessionId: "" })); + assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1, sessionId: " " })); + assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1 })); +}); + +function openHandsModeState(availableModeIds: ReadonlyArray): AcpSessionModeState { + return { + currentModeId: "always-ask", + availableModes: availableModeIds.map((id) => ({ id, name: id })), + }; +} + +it("resolves undefined without mode state, since OpenHands has nothing to switch", () => { + assert.isUndefined( + resolveRequestedModeId({ + interactionMode: "default", + runtimeMode: "full-access", + modeState: undefined, + }), + ); +}); + +it("maps runtime mode to the matching OpenHands confirmation mode", () => { + const modeState = openHandsModeState(["always-ask", "llm-approve", "always-approve"]); + assert.equal( + resolveRequestedModeId({ + interactionMode: "default", + runtimeMode: "approval-required", + modeState, + }), + "always-ask", + ); + assert.equal( + resolveRequestedModeId({ interactionMode: "default", runtimeMode: "auto", modeState }), + "llm-approve", + ); + assert.equal( + resolveRequestedModeId({ + interactionMode: "default", + runtimeMode: "auto-accept-edits", + modeState, + }), + "llm-approve", + ); + assert.equal( + resolveRequestedModeId({ interactionMode: "default", runtimeMode: "full-access", modeState }), + "always-approve", + ); +}); + +it("forces always-ask for plan mode regardless of runtime mode", () => { + const modeState = openHandsModeState(["always-ask", "llm-approve", "always-approve"]); + assert.equal( + resolveRequestedModeId({ interactionMode: "plan", runtimeMode: "full-access", modeState }), + "always-ask", + ); +}); + +it("falls back to the agent's current mode when the requested mode isn't offered", () => { + const modeState = openHandsModeState(["always-ask"]); + assert.equal( + resolveRequestedModeId({ interactionMode: "default", runtimeMode: "full-access", modeState }), + "always-ask", + ); +}); + +function openHandsPermissionRequest( + options: ReadonlyArray<{ + readonly optionId: string; + readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + }>, +) { + return { + sessionId: "mock-session-1", + toolCall: { + toolCallId: "tool-call-1", + title: "cat package.json", + kind: "execute" as const, + status: "pending" as const, + }, + options: options.map((option) => ({ + optionId: option.optionId, + name: option.kind, + kind: option.kind, + })), + }; +} + +it("maps accept decisions to allow_once, preferring it over allow_always", () => { + const request = openHandsPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + assert.equal(selectPermissionOptionId(request, "accept"), "allow-once"); +}); + +it("maps acceptForSession and acceptAlways decisions to allow_always when offered", () => { + const request = openHandsPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + assert.equal(selectPermissionOptionId(request, "acceptForSession"), "allow-always"); + assert.equal(selectPermissionOptionId(request, "acceptAlways"), "allow-always"); +}); + +it("falls back to allow_once when OpenHands omits allow_always", () => { + const request = openHandsPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + assert.equal(selectPermissionOptionId(request, "acceptForSession"), "allow-once"); +}); + +it("maps decline to reject_once", () => { + const request = openHandsPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + assert.equal(selectPermissionOptionId(request, "decline"), "reject-once"); +}); + +it("returns undefined when no option matches the decision's kinds", () => { + const request = openHandsPermissionRequest([{ optionId: "allow-once", kind: "allow_once" }]); + assert.isUndefined(selectPermissionOptionId(request, "decline")); +}); + +it.layer(openHandsAdapterTestLayer)("OpenHandsAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("openhands-mock-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockOpenHandsWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("openhands"), model: "openhands" }, + }); + + assert.equal(session.provider, "openhands"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello openhands", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "turn.completed", + ] as const); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenHandsAdapter.ts b/apps/server/src/provider/Layers/OpenHandsAdapter.ts new file mode 100644 index 000000000000..6310d82071e7 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenHandsAdapter.ts @@ -0,0 +1,960 @@ +/** + * OpenHandsAdapterLive — OpenHands CLI (`openhands acp`) via ACP. + * + * @module OpenHandsAdapterLive + */ + +import { + ApprovalRequestId, + EventId, + type OpenHandsSettings, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { type AcpSessionModeState, parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + OPENHANDS_ALWAYS_ASK_MODE_ID, + makeOpenHandsAcpRuntime, + openHandsAcpModeId, + resolveOpenHandsAcpBaseModelId, +} from "../acp/OpenHandsAcpSupport.ts"; +import { type OpenHandsAdapterShape } from "../Services/OpenHandsAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("openhands"); +const OPENHANDS_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface OpenHandsAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`openhands`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `openHandsSettings` captured at construction. Production instances + * leave this undefined; test suites that mutate `ServerSettingsService` + * mid-flight pass a resolver that reads the latest snapshot. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface OpenHandsSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseOpenHandsResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== OPENHANDS_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +/** + * OpenHands names its confirmation modes `always-ask`, `llm-approve`, and + * `always-approve`, so the runtime mode maps onto a mode id directly. Plan is + * not one of them — the agent has no read-only mode — so a plan turn falls back + * to `always-ask` and every edit stays behind an approval. + */ +export function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + const requested = + input.interactionMode === "plan" + ? OPENHANDS_ALWAYS_ASK_MODE_ID + : openHandsAcpModeId(input.runtimeMode); + return modeState.availableModes.some((mode) => mode.id === requested) + ? requested + : modeState.currentModeId; +} + +/** + * Resolves the option id OpenHands expects for a decision. The option ids are + * agent-defined (`accept`, `reject`, `always_proceed`), so they are looked up + * through the ACP `kind` instead of assumed. + */ +export function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const preferredKinds = + decision === "acceptAlways" || decision === "acceptForSession" + ? (["allow_always", "allow_once"] as const) + : decision === "accept" + ? (["allow_once", "allow_always"] as const) + : (["reject_once", "reject_always"] as const); + for (const kind of preferredKinds) { + const optionId = request.options.find((option) => option.kind === kind)?.optionId; + if (typeof optionId === "string" && optionId.trim()) { + return optionId.trim(); + } + } + return undefined; +} + +export function makeOpenHandsAdapter( + openHandsSettings: OpenHandsSettings, + options?: OpenHandsAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("openhands"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate OpenHands runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapHandlerFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process OpenHands ACP request.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: OpenHandsSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: OpenHandsSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const applyRequestedMode = (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly threadId: ThreadId; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + }) => + Effect.gen(function* () { + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + yield* input.runtime + .setMode(requestedModeId) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + ), + ); + }); + + const startSession: OpenHandsAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const openHandsModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: OpenHandsSessionContext; + + const resumeSessionId = parseOpenHandsResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveOpenHandsSettings = options?.resolveSettings + ? yield* options.resolveSettings + : openHandsSettings; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeOpenHandsAcpRuntime({ + openHandsSettings: effectiveOpenHandsSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + runtimeMode: input.runtimeMode, + // OpenHands advertises `loadSession`, so a stored session id is + // replayed through `session/load` instead of starting over. + ...(resumeSessionId ? { resumeSessionId, resumeMethod: "load" as const } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapHandlerFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectPermissionOptionId( + params, + "acceptForSession", + ); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + if (resolved === "cancel") { + return { outcome: { outcome: "cancelled" } as const }; + } + const optionId = selectPermissionOptionId(params, resolved); + // An agent that offers no option for the decision leaves + // cancellation as the only truthful answer. + return optionId === undefined + ? { outcome: { outcome: "cancelled" } as const } + : { outcome: { outcome: "selected" as const, optionId } }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedMode({ + runtime: acp, + threadId: input.threadId, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: openHandsModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: OPENHANDS_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process OpenHands runtime notification.", { cause }), + ), + // Fork into the session scope so the consumer outlives the + // `startSession` fiber; see CursorAdapter for the same trap. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "OpenHands ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: OpenHandsAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const resolvedModel = resolveOpenHandsAcpBaseModelId( + turnModelSelection?.model ?? ctx.session.model, + ); + yield* applyRequestedMode({ + runtime: ctx.acp, + threadId: input.threadId, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const promptParts: Array = []; + const rawPrompt = input.input?.trim() ?? ""; + if (rawPrompt) { + promptParts.push({ type: "text", text: rawPrompt }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + // OpenHands advertises image prompt capability only. Generic + // files reach the agent through the path line ProviderService + // puts in the prompt. + if (attachment.type !== "image") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // ACP has no system-message field; keep runtime context separate from the user's text. + const result = yield* ctx.acp + .prompt({ + prompt: [ + ...promptParts, + { + type: "text", + text: buildRuntimeInstructions({ harness: "OpenHands", model: resolvedModel }), + }, + ], + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + yield* ctx.acp.drainEvents; + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: OpenHandsAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: OpenHandsAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + // OpenHands has no structured user-input request over ACP; questions come + // back as ordinary assistant text. + const respondToUserInput: OpenHandsAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + }); + + const readThread: OpenHandsAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: OpenHandsAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: OpenHandsAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: OpenHandsAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: OpenHandsAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: OpenHandsAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit OpenHands session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + // The OpenHands ACP agent negotiates its model out of band (`~/.openhands` + // config), so there is no in-session model configuration option. + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies OpenHandsAdapterShape; + }); +} diff --git a/apps/server/src/provider/Services/OpenHandsAdapter.ts b/apps/server/src/provider/Services/OpenHandsAdapter.ts new file mode 100644 index 000000000000..a80bc54d1cc2 --- /dev/null +++ b/apps/server/src/provider/Services/OpenHandsAdapter.ts @@ -0,0 +1,17 @@ +/** + * OpenHandsAdapter — shape type for the OpenHands provider adapter. + * + * Like {@link ../Drivers/CursorDriver}, the driver bundles one adapter per + * instance as a captured closure, so there is no `Context.Service` tag here — + * only the shape interface as a naming anchor for the driver bundle. + * + * @module OpenHandsAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenHandsAdapterShape — per-instance OpenHands adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface OpenHandsAdapterShape extends ProviderAdapterShape {} From 155fadeefd5c5a210f677c294fc621005228fc87 Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 18:21:06 -0600 Subject: [PATCH 05/11] feat(provider): add OpenHands status probing and snapshot building Probes OpenHands CLI 1.16.0 plus an ACP initialize handshake for health, and builds the provider snapshot. No model catalog refresh (OpenHands has none) and auth always reports unknown, since a local install authenticates via ~/.openhands rather than a CLI login step. Co-authored-by: openhands --- .../provider/Layers/OpenHandsProvider.test.ts | 150 +++++++++ .../src/provider/Layers/OpenHandsProvider.ts | 305 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 apps/server/src/provider/Layers/OpenHandsProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenHandsProvider.ts diff --git a/apps/server/src/provider/Layers/OpenHandsProvider.test.ts b/apps/server/src/provider/Layers/OpenHandsProvider.test.ts new file mode 100644 index 000000000000..2136f11ce964 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenHandsProvider.test.ts @@ -0,0 +1,150 @@ +// @effect-diagnostics nodeBuiltinImport:off - resolves mock ACP agent script path relative to this test file. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { OpenHandsSettings } from "@t3tools/contracts"; + +import { + buildInitialOpenHandsProviderSnapshot, + checkOpenHandsProviderStatus, +} from "./OpenHandsProvider.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); + +describe("buildInitialOpenHandsProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialOpenHandsProviderSnapshot( + decodeOpenHandsSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns disabled by default — OpenHands is opt-in", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialOpenHandsProviderSnapshot(decodeOpenHandsSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialOpenHandsProviderSnapshot( + decodeOpenHandsSettings({ enabled: true }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking OpenHands"); + }), + ); +}); + +it.layer(NodeServices.layer)("checkOpenHandsProviderStatus", (it) => { + it.effect("reports binary as missing when binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenHandsProviderStatus( + decodeOpenHandsSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/openhands-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-openhands-version-" }); + const openHandsPath = writeFakeCli({ + directory: dir, + name: "openhands", + source: ["process.exit(2);"].join("\n"), + }); + return yield* checkOpenHandsProviderStatus( + decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), + ); + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("OpenHands CLI is installed but failed to run."); + }), + ); + + const writeFakeOpenHandsCli = (input: { readonly acp: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-openhands-probe-" }); + return writeFakeCli({ + directory: dir, + name: "openhands", + source: [ + 'if (process.argv[2] === "--version") {', + ' process.stdout.write("openhands 1.16.0\\n");', + " process.exit(0);", + "}", + 'if (process.argv[2] !== "acp") process.exit(1);', + ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), + "", + ].join("\n"), + }); + }); + + it.effect("reports ready when the ACP initialize probe succeeds", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const openHandsPath = yield* writeFakeOpenHandsCli({ acp: true }); + return yield* checkOpenHandsProviderStatus( + decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.16.0"); + expect(snapshot.auth).toEqual({ status: "unknown" }); + }), + ); + + it.effect("falls back to a warning when the ACP initialize probe fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const openHandsPath = yield* writeFakeOpenHandsCli({ acp: false }); + return yield* checkOpenHandsProviderStatus( + decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.16.0"); + expect(snapshot.message).toContain("ACP initialize failed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenHandsProvider.ts b/apps/server/src/provider/Layers/OpenHandsProvider.ts new file mode 100644 index 000000000000..69c52c412049 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenHandsProvider.ts @@ -0,0 +1,305 @@ +/** + * OpenHandsProvider — status probing and snapshot building for the OpenHands CLI. + * + * OpenHands has no `models` listing command and its ACP session advertises no model + * state (see {@link ../acp/OpenHandsAcpSupport}), so unlike Grok this probe never + * discovers additional models. It also has no reliable local signal for auth state: + * a local install resolves its LLM credentials from `~/.openhands`, not from a CLI + * login step, so auth always reports `"unknown"` rather than guessing. + * + * @module OpenHandsProvider + */ +import { + type CustomModelSetting, + type ModelCapabilities, + type OpenHandsSettings, + type ServerProvider, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + OPENHANDS_DEFAULT_MODEL_SLUG, + makeOpenHandsAcpRuntime, +} from "../acp/OpenHandsAcpSupport.ts"; + +const OPENHANDS_PRESENTATION = { + displayName: "OpenHands", + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +// `initialize` is a single local round trip, so this is generous even on slow machines. +const OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS = 8_000; + +const OPENHANDS_BUILT_IN_MODELS = [ + { + slug: OPENHANDS_DEFAULT_MODEL_SLUG, + name: "OpenHands Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialOpenHandsProviderSnapshot( + openHandsSettings: OpenHandsSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = openHandsModelsFromSettings(openHandsSettings.customModels); + + if (!openHandsSettings.enabled) { + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenHands is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenHands CLI availability...", + }, + }); + }); +} + +function openHandsModelsFromSettings(customModels: ReadonlyArray | undefined) { + return providerModelsFromSettings( + OPENHANDS_BUILT_IN_MODELS, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runOpenHandsCliCommand = ( + openHandsSettings: OpenHandsSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = openHandsSettings.binaryPath || "openhands"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +/** + * Confirms `openhands acp` can complete the ACP handshake. Only `initialize` is + * called — never `authenticate` or `session/new` — so this cannot open a browser + * login or boot the workspace's MCP servers. It exists to catch spawn-level + * breakage (wrong binary, broken entry point) that a bare `--version` probe would + * miss, since `openhands acp` and `openhands` share an entry point but not a code + * path once the subcommand dispatches. + */ +const probeOpenHandsAcpInitialize = ( + openHandsSettings: OpenHandsSettings, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeOpenHandsAcpRuntime({ + openHandsSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + yield* acp.initialize(); + }).pipe(Effect.scoped); + +export const checkOpenHandsProviderStatus = Effect.fn("checkOpenHandsProviderStatus")(function* ( + openHandsSettings: OpenHandsSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = openHandsModelsFromSettings(openHandsSettings.customModels); + + if (!openHandsSettings.enabled) { + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenHands is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runOpenHandsCliCommand( + openHandsSettings, + ["--version"], + environment, + ).pipe(Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("OpenHands CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: openHandsSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "OpenHands CLI (`openhands`) is not installed or not on PATH." + : "Failed to execute OpenHands CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: openHandsSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "OpenHands CLI is installed but timed out while running `openhands --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("OpenHands CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: openHandsSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "OpenHands CLI is installed but failed to run.", + }, + }); + } + + const acpExit = yield* probeOpenHandsAcpInitialize(openHandsSettings, environment).pipe( + Effect.timeoutOption(OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS), + Effect.exit, + ); + const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); + if (acpFailed) { + yield* Effect.logWarning("OpenHands ACP initialize probe failed or timed out.", { + errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", + }); + } + + return buildServerProvider({ + presentation: OPENHANDS_PRESENTATION, + enabled: openHandsSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + // A failed ACP probe degrades the chat experience, it does not make the + // CLI itself unusable, so this is a warning rather than an error. + status: acpFailed ? "warning" : "ready", + auth: { status: "unknown" }, + ...(acpFailed + ? { + message: + "OpenHands CLI is installed but ACP initialize failed. Chat sessions may not start.", + } + : {}), + }, + }); +}); + +export const enrichOpenHandsSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("OpenHands version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; From b8577de9976bdf21115d1ebb2cb515505eeae508 Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 18:21:30 -0600 Subject: [PATCH 06/11] feat(textGeneration): add OpenHands headless text generation Spawns 'openhands acp' with tool capabilities disabled and always-ask mode for one-shot structured output (commit messages, PR content, thread titles, branch names), following GrokTextGeneration's shape. Co-authored-by: openhands --- .../OpenHandsTextGeneration.test.ts | 220 +++++++++++++++ .../textGeneration/OpenHandsTextGeneration.ts | 253 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 apps/server/src/textGeneration/OpenHandsTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/OpenHandsTextGeneration.ts diff --git a/apps/server/src/textGeneration/OpenHandsTextGeneration.test.ts b/apps/server/src/textGeneration/OpenHandsTextGeneration.test.ts new file mode 100644 index 000000000000..d7589d9c8bde --- /dev/null +++ b/apps/server/src/textGeneration/OpenHandsTextGeneration.test.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; +import * as NodeFS from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vite-plus/test"; +import { OpenHandsSettings, ProviderInstanceId } from "@t3tools/contracts"; + +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { makeOpenHandsTextGeneration } from "./OpenHandsTextGeneration.ts"; +import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); + +const OpenHandsTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-openhands-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeAcpOpenHandsWrapper(dir: string, env: Record): string { + return writeFakeCli({ + directory: NodePath.join(dir, "bin"), + name: "openhands", + env, + source: execScriptSource({ + scriptPath: mockAgentPath, + expectedArgs: ["acp"], + }), + }); +} + +function withFakeAcpOpenHands( + env: Record, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-openhands-text-acp-"), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const binaryPath = makeAcpOpenHandsWrapper(tempDir, env); + const config = decodeOpenHandsSettings({ binaryPath }); + const textGeneration = yield* makeOpenHandsTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function readJsonRpcRequests( + filePath: string, +): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { + return NodeFS.readFileSync(filePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: Record }); +} + +// OpenHands has no model catalog, so `session/set_model` is never sent; the +// requested model id only routes commands to this driver. +const modelSelection = createModelSelection( + ProviderInstanceId.make("openhands"), + "openhands-default", +); + +it.layer(OpenHandsTextGenerationTestLayer)("OpenHandsTextGeneration", (it) => { + it.effect("uses ACP with disabled tool capabilities and always-ask mode", () => { + const requestLogDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-openhands-text-log-"), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpOpenHands( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add OpenHands provider", + body: "Wire up the ACP runtime and headless text generation path.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/openhands", + stagedSummary: "M apps/server/src/provider/Drivers/OpenHandsDriver.ts", + stagedPatch: "diff --git a/.../OpenHandsDriver.ts b/.../OpenHandsDriver.ts", + modelSelection, + }); + + expect(generated.subject).toBe("Add OpenHands provider"); + expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); + + const requests = readJsonRpcRequests(requestLogPath); + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }); + expect(requests.some((request) => request.method === "session/set_model")).toBe(false); + }), + ); + }); + + it.effect("extracts the JSON object when OpenHands wraps it in conversational text", () => + withFakeAcpOpenHands( + { + T3_ACP_PROMPT_RESPONSE_TEXT: + "Sure! Here's a thread title:\n\n" + + JSON.stringify({ title: "Investigate failing CI" }) + + "\n\nLet me know if you need anything else.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "the lint job is red", + modelSelection, + }); + expect(generated.title).toBe("Investigate failing CI"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is empty", () => + withFakeAcpOpenHands( + { + T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection, + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/empty/i); + }), + ), + ); + + it.effect("decodes a structured PR title + body", () => + withFakeAcpOpenHands( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: "feat(openhands): wire up ACP text generation", + body: "## Summary\n- Spawn `openhands acp` for headless text generation.\n- Extract JSON output from conversational wrapping.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feat/openhands-provider", + commitSummary: "feat: add openhands provider", + diffSummary: "M apps/server/src/provider/Drivers/OpenHandsDriver.ts", + diffPatch: "diff --git a/.../OpenHandsDriver.ts b/.../OpenHandsDriver.ts", + modelSelection, + }); + + expect(generated.title).toBe("feat(openhands): wire up ACP text generation"); + expect(generated.body).toContain("Spawn `openhands acp`"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is unparseable JSON", () => + withFakeAcpOpenHands( + { + T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection, + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/invalid structured output/i); + }), + ), + ); + + it.effect("decodes a branch name suggestion", () => + withFakeAcpOpenHands( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "feature/wire-up-openhands" }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "wire up openhands", + modelSelection, + }); + expect(generated.branch).toBe("feature/wire-up-openhands"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/OpenHandsTextGeneration.ts b/apps/server/src/textGeneration/OpenHandsTextGeneration.ts new file mode 100644 index 000000000000..612bf6854329 --- /dev/null +++ b/apps/server/src/textGeneration/OpenHandsTextGeneration.ts @@ -0,0 +1,253 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type OpenHandsSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { TextGenerationError } from "@t3tools/contracts"; + +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + OPENHANDS_ALWAYS_ASK_MODE_ID, + makeOpenHandsAcpRuntime, +} from "../provider/acp/OpenHandsAcpSupport.ts"; + +const OPENHANDS_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Build an OpenHands text-generation closure bound to a specific + * `OpenHandsSettings` payload. See `makeCodexAdapter` for the overall + * per-instance rationale. + * + * The helper runs the agent in `always-ask` and never registers a permission + * handler, so any tool the model reaches for stalls instead of touching the + * repository — these prompts only ever need text back. + */ +export const makeOpenHandsTextGeneration = Effect.fn("makeOpenHandsTextGeneration")(function* ( + openHandsSettings: OpenHandsSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runOpenHandsJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeOpenHandsAcpRuntime({ + openHandsSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode(OPENHANDS_ALWAYS_ASK_MODE_ID)); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(OPENHANDS_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "OpenHands request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "OpenHands ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "OpenHands ACP request was cancelled." + : "OpenHands returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "OpenHands returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "OpenHands ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenHandsTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runOpenHandsJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenHandsTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runOpenHandsJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenHandsTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runOpenHandsJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenHandsTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runOpenHandsJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); From 224575f76af02212f4db95ed9924b4c426d1eecc Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 18:21:37 -0600 Subject: [PATCH 07/11] feat(provider): register OpenHandsDriver as a built-in driver Adds OpenHandsDriver (ProviderDriver bundling the ACP adapter, status probe/snapshot, and text generation built in the previous commits) and registers it in BUILT_IN_DRIVERS/BuiltInDriversEnv per the 3-step recipe in builtInDrivers.ts's module doc-comment. OpenHands uses manual-only maintenance capabilities: it ships via 'uv tool install', which providerMaintenance.ts's installer-ownership resolver cannot attribute to a specific installer. Co-authored-by: openhands --- .../provider/Drivers/OpenHandsDriver.test.ts | 89 ++++++++++ .../src/provider/Drivers/OpenHandsDriver.ts | 153 ++++++++++++++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/builtInDrivers.ts | 3 + 4 files changed, 246 insertions(+) create mode 100644 apps/server/src/provider/Drivers/OpenHandsDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/OpenHandsDriver.ts diff --git a/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts b/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts new file mode 100644 index 000000000000..3294f71aae97 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodePath from "node:path"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { OpenHandsDriver } from "./OpenHandsDriver.ts"; + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-openhands-driver-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled OpenHands must not make an HTTP request")), + ), + ), +); + +it.layer(testLayer)("OpenHandsDriver", (it) => { + it.effect('disabled instance reports status "disabled" and never spawns a process', () => + Effect.gen(function* () { + const instance = yield* OpenHandsDriver.create({ + instanceId: ProviderInstanceId.make("openhands-disabled"), + displayName: "OpenHands test", + enabled: false, + environment: [], + config: OpenHandsDriver.defaultConfig(), + }); + expect((yield* instance.snapshot.refresh).status).toBe("disabled"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("Disabled OpenHands must not spawn a process")), + ), + Effect.scoped, + ), + ); + + // Unlike Cursor and Codex, OpenHands has no proven update-installer path (it + // ships via `uv tool install`), so maintenance is manual-only regardless of + // whether the configured executable exists. + it.effect("stays manual-only regardless of the configured executable", () => + Effect.gen(function* () { + const instance = yield* OpenHandsDriver.create({ + instanceId: ProviderInstanceId.make("openhands-manual-only"), + displayName: "OpenHands test", + enabled: false, + environment: [], + config: { + ...OpenHandsDriver.defaultConfig(), + binaryPath: NodePath.join("does", "not", "exist", "openhands"), + }, + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.die("OpenHands must not spawn a process to resolve maintenance"), + ), + ), + Effect.scoped, + ), + ); + + it("default config is disabled with the bare `openhands` binary", () => { + const config = OpenHandsDriver.defaultConfig(); + expect(config.enabled).toBe(false); + expect(config.binaryPath).toBe("openhands"); + expect(config.customModels).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Drivers/OpenHandsDriver.ts b/apps/server/src/provider/Drivers/OpenHandsDriver.ts new file mode 100644 index 000000000000..ba415f79ab2d --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenHandsDriver.ts @@ -0,0 +1,153 @@ +/** + * OpenHandsDriver — `ProviderDriver` for the OpenHands CLI (`openhands acp`). + * + * OpenHands exposes an ACP-based CLI, like Grok and Cursor. Unlike either, it has no + * model catalog to refresh (its ACP session advertises no model state) and no proven + * update-installer path (it ships via `uv tool install`, which `providerMaintenance.ts` + * cannot attribute to a specific owner), so this driver skips catalog refresh and uses + * manual-only maintenance capabilities. See `../acp/OpenHandsAcpSupport.ts` for the + * `openhands-acp` binary breakage this driver works around. + * + * @module provider/Drivers/OpenHandsDriver + */ +import { OpenHandsSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOpenHandsTextGeneration } from "../../textGeneration/OpenHandsTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenHandsAdapter } from "../Layers/OpenHandsAdapter.ts"; +import { + buildInitialOpenHandsProviderSnapshot, + checkOpenHandsProviderStatus, + enrichOpenHandsSnapshot, +} from "../Layers/OpenHandsProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); + +const DRIVER_KIND = ProviderDriverKind.make("openhands"); +const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type OpenHandsDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +export const OpenHandsDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenHands", + supportsMultipleInstances: true, + }, + configSchema: OpenHandsSettings, + defaultConfig: (): OpenHandsSettings => decodeOpenHandsSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenHandsSettings; + const adapter = yield* makeOpenHandsAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeOpenHandsTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkOpenHandsProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialOpenHandsProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichOpenHandsSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities: MAINTENANCE_CAPABILITIES, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenHands snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd: () => snapshot.getSnapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..abc9568c1d94 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2617,6 +2617,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "openhands", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..8a2f68953b7c 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenHandsDriver, type OpenHandsDriverEnv } from "./Drivers/OpenHandsDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -39,6 +40,7 @@ export type BuiltInDriversEnv = | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv + | OpenHandsDriverEnv | AntigravityDriverEnv; /** @@ -52,5 +54,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Date: Tue, 8 Sep 2026 18:31:28 -0600 Subject: [PATCH 08/11] docs: add SUMMARY.md for OpenHands provider driver work Summarizes what was implemented, the openhands-acp binary investigation and workaround, what was verified (typecheck, full test suite, manual ACP probes) vs. unverified (live end-to-end conversation turn), and open questions for future work. Co-authored-by: openhands --- SUMMARY.md | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 SUMMARY.md diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 000000000000..2d969819af6f --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,117 @@ +# OpenHands provider driver + +Adds OpenHands (github.com/All-Hands-AI/OpenHands) as a built-in ACP-based provider driver, +following the Grok/Cursor pattern documented in `docs/internals/providers.md` and the 3-step +recipe in `apps/server/src/provider/builtInDrivers.ts`. + +## What was implemented + +- `packages/contracts/src/settings.ts` — `OpenHandsSettings` schema (`enabled`, `binaryPath`, + `customModels`), same shape as `GrokSettings`/`CursorSettings`. No invented fields. +- `packages/contracts/src/model.ts` — `OPENHANDS_DRIVER_KIND`, `OPENHANDS_DEFAULT_MODEL` sentinel + model, mirroring the Antigravity pattern (OpenHands has no `models` listing command). +- `apps/server/src/provider/acp/OpenHandsAcpSupport.ts` — spawn/runtime wiring, modeled on + `GrokAcpSupport.ts`. Builds the spawn command/args/env and constructs the shared + `AcpSessionRuntime`. Maps T3 `RuntimeMode` to OpenHands' `--llm-approve`/`--always-approve` CLI + flags and ACP confirmation-mode ids (`always-ask`/`llm-approve`/`always-approve`). +- `apps/server/src/provider/Services/OpenHandsAdapter.ts` + + `apps/server/src/provider/Layers/OpenHandsAdapter.ts` — the `ProviderAdapter` implementation + translating OpenHands' ACP session events (tool calls, permission requests, mode changes) into + T3 orchestration events, modeled on `CursorAdapter`/`GrokAdapter`. +- `apps/server/src/provider/Layers/OpenHandsProvider.ts` — status probing (`openhands --version`) + and `ServerProvider` snapshot building, modeled on `GrokProvider.ts`. No model catalog probe + (none exists) and no auth-state guess (OpenHands resolves LLM credentials from `~/.openhands` + out of band, not via CLI login, so auth always reports `"unknown"`). +- `apps/server/src/textGeneration/OpenHandsTextGeneration.ts` — headless text-generation helper + (tools disabled, always-ask mode), modeled on the Grok/Cursor text-generation helpers. +- `apps/server/src/provider/Drivers/OpenHandsDriver.ts` — the `ProviderDriver` bundling the above + (driverKind, metadata, configSchema, defaultConfig, create), modeled on `GrokDriver.ts`. +- `apps/server/src/provider/builtInDrivers.ts` — registered `OpenHandsDriver`: import, + `BuiltInDriversEnv` union entry, `BUILT_IN_DRIVERS` array entry. This is the only change to a + shared registry file. +- `apps/server/src/provider/acp/AcpSessionRuntime.ts` — one small, backward-compatible change: + `authMethodId` is now optional (`readonly authMethodId?: string`), and the `authenticate` RPC + call is skipped when it's omitted. This was necessary, not incidental: ACP's `authenticate` is + not optional per the protocol, but OpenHands only advertises an interactive cloud OAuth + device-flow auth method. A local install is already authenticated out of band via `~/.openhands` + credentials, so sending `authenticate` on every session would either be rejected or would open a + browser login each time. All existing callers (Grok, Cursor, Antigravity) still pass + `authMethodId` explicitly, so this is additive and doesn't change their behavior — confirmed via + `grep` across `apps/server/src/provider/` and the full test suite. +- Unit tests alongside every new file (`*.test.ts`), following the existing pattern next to + `GrokDriver.ts`/`GrokAcpSupport.ts`: `OpenHandsDriver.test.ts`, `OpenHandsAdapter.test.ts`, + `OpenHandsProvider.test.ts`, `OpenHandsAcpSupport.test.ts`, `OpenHandsTextGeneration.test.ts`. + Existing `ProviderRegistry.test.ts` updated to include the new driver in the registry-wide + assertions. + +## The `openhands-acp` binary investigation + +The locally installed CLI (`openhands` 1.16.0 via `uv tool install openhands`) ships an +`openhands-acp` console script that is broken: + +``` +ModuleNotFoundError: No module named 'openhands_cli.acp' +``` + +Inspecting `~/.local/share/uv/tools/openhands/`: + +- The generated entry point (`~/.local/share/uv/tools/openhands/bin/openhands-acp`) targets + `openhands_cli.acp:main`. +- The installed wheel only contains `openhands_cli.acp_impl`, not a top-level `openhands_cli.acp` + module — an upstream packaging bug where the console-script entry point and the actual module + layout diverged (visible comparing the wheel's `dist-info` entry_points to the on-disk package + tree). +- `openhands acp` (the CLI's own `acp` subcommand, not the separate `openhands-acp` binary) runs + the same ACP server code (imports the working `openhands_cli.acp_impl` internally) and does not + hit the broken import path. + +**Workaround used**: spawn `openhands acp` instead of the separate `openhands-acp` binary. This is +implemented in `OpenHandsAcpSupport.ts` with a doc comment explaining why, and a note to switch +back to the dedicated binary once the upstream entry point is fixed. + +## Verified vs. unverified + +**Verified:** + +- `pnpm run typecheck` in `apps/server` — passes, 0 errors, nothing OpenHands-related flagged. +- Full test suite (`vp test run`, which ran the whole `apps/server` suite rather than just the + filtered files) — 298 test files passed (2 skipped), 4285 tests passed (10 skipped), 0 + failures. All new OpenHands unit tests are part of this run and pass. +- `openhands acp --help` runs successfully (confirms the subcommand exists and the workaround is + viable at all). +- Manually piped JSON-RPC `initialize` and `session/new` requests into `openhands acp` over + stdio. The process starts, prints its startup banner and an SDK warning to stderr (as expected — + `OPENHANDS_SUPPRESS_BANNER=1` is set by the driver to suppress this), but no JSON-RPC response + was observed on stdout in the manual probe. This is inconclusive: it may need a longer timeout, + a real workspace directory, or valid LLM credentials configured in `~/.openhands` to progress + past session setup, none of which were readily available in this sandbox. + +**Unverified (real end-to-end run):** + +- Whether `openhands acp`'s actual ACP `initialize`/`session/new`/`prompt` responses match the + message shapes assumed in `OpenHandsAcpSupport.ts` and `OpenHandsAdapter.ts` (tool-call + structure, permission-request structure, mode-change notifications, session update ids). These + were built against the protocol spec and by close analogy with `GrokAcpSupport.ts`/ + `CursorAcpSupport.ts`/`AcpJsonRpcConnection.ts`, but a full live conversation turn (prompt → + tool call → permission grant → response) was not observed end-to-end against a real, credentialed + OpenHands agent in this environment. +- The `--llm-approve` / `--always-approve` CLI flag names and the `always-ask`/`llm-approve`/ + `always-approve` ACP mode ids are inferred from `openhands acp --help` output and are + reasonable-effort matches to T3's `RuntimeMode`, but weren't exercised through a live mode + switch. +- Whether a local install genuinely never needs `authenticate` in all configurations (e.g. if a + user has no `~/.openhands` credentials configured yet) — the driver assumes "already + authenticated or fails visibly," consistent with `OpenHandsProvider.ts` always reporting + `"unknown"` auth state rather than guessing. + +## Open questions + +- Confirm the real shape of OpenHands' `session/update` notifications (tool call granularity, + permission option ids) against a live, credentialed session once available, and adjust + `OpenHandsAdapter.ts` if it diverges from the Grok/Cursor-derived assumptions. +- Track the upstream `openhands-acp` entry-point bug and switch `OpenHandsAcpSupport.ts` back to + spawning the dedicated binary once fixed upstream (both should be equivalent once fixed, but the + dedicated binary keeps the process name and lifecycle distinct from other `openhands` CLI usage). +- Decide whether `binaryPath` should default-resolve through the same `uv tool` install location + T3 config expects for other CLI-shelling providers, or whether `PATH` resolution (current + behavior, matching Grok/Cursor) is sufficient. From af77a58749e8c37c429e501914e7b013c4b7ff71 Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 8 Sep 2026 23:10:16 -0600 Subject: [PATCH 09/11] fix(provider): bump OpenHands probe timeouts for cold Python startup 4s/8s intermittently timed out on cold `openhands --version`/`acp initialize` (uv-managed Python CLI, ~4s cold start); 15s covers process boot plus the round trip. Also folds in the 2026-09-08 live end-to-end ACP verification notes into SUMMARY.md. --- SUMMARY.md | 77 +++++++++++++------ .../src/provider/Layers/OpenHandsProvider.ts | 10 ++- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/SUMMARY.md b/SUMMARY.md index 2d969819af6f..e5ef2dff7804 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -79,39 +79,70 @@ back to the dedicated binary once the upstream entry point is fixed. failures. All new OpenHands unit tests are part of this run and pass. - `openhands acp --help` runs successfully (confirms the subcommand exists and the workaround is viable at all). -- Manually piped JSON-RPC `initialize` and `session/new` requests into `openhands acp` over - stdio. The process starts, prints its startup banner and an SDK warning to stderr (as expected — - `OPENHANDS_SUPPRESS_BANNER=1` is set by the driver to suppress this), but no JSON-RPC response - was observed on stdout in the manual probe. This is inconclusive: it may need a longer timeout, - a real workspace directory, or valid LLM credentials configured in `~/.openhands` to progress - past session setup, none of which were readily available in this sandbox. - -**Unverified (real end-to-end run):** - -- Whether `openhands acp`'s actual ACP `initialize`/`session/new`/`prompt` responses match the - message shapes assumed in `OpenHandsAcpSupport.ts` and `OpenHandsAdapter.ts` (tool-call - structure, permission-request structure, mode-change notifications, session update ids). These - were built against the protocol spec and by close analogy with `GrokAcpSupport.ts`/ - `CursorAcpSupport.ts`/`AcpJsonRpcConnection.ts`, but a full live conversation turn (prompt → - tool call → permission grant → response) was not observed end-to-end against a real, credentialed - OpenHands agent in this environment. -- The `--llm-approve` / `--always-approve` CLI flag names and the `always-ask`/`llm-approve`/ - `always-approve` ACP mode ids are inferred from `openhands acp --help` output and are - reasonable-effort matches to T3's `RuntimeMode`, but weren't exercised through a live mode - switch. + +**Verified live end-to-end (2026-09-08, against the installed `openhands` 1.16.0):** + +A full ACP conversation turn was driven over stdio against a real, credentialed OpenHands agent +(LLM = local Ollama, `gemma-4-12B-it-qat`, via `OPENHANDS_PERSISTENCE_DIR` pointing at an isolated +config). Every message shape the driver assumes was confirmed against live output: + +- `initialize` → `agentCapabilities` (`loadSession: true`, `mcpCapabilities` http+sse, + `promptCapabilities` audio/embeddedContext/image), `agentInfo` "OpenHands CLI ACP Agent" + 1.16.0, `authMethods` = only `[oauth]`. Confirms the driver's skip-`authenticate` decision. +- `session/new` → `sessionId` + `modes.availableModes` with ids exactly + `always-ask`/`llm-approve`/`always-approve` and `currentModeId: "always-ask"` — matches + `OPENHANDS_ALWAYS_ASK_MODE_ID`/`openHandsAcpModeId` and the `--llm-approve`/`--always-approve` + spawn flags. +- `session/prompt` → `prompt` must be a **list of content blocks** (`[{type:"text",text:...}]`), + not a string. The driver already sends a list; a string prompt returns + `-32602 Invalid params (list_type)`. +- `session/request_permission` notification → `{options:[{kind,optionId,name}...], sessionId, +toolCall:{...}}` with kinds `allow_once`/`reject_once`/`allow_always`. The driver's + `selectPermissionOptionId` lookup by kind resolves correctly (`accept`→`allow_once`, + `acceptAlways`→`allow_always`, `reject`→`reject_once`), and `parsePermissionRequest` reads + exactly the `toolCall` fields OpenHands sends. Responding with + `{outcome:{outcome:"selected",optionId:"accept"}}` completes the approval. +- `session/update` notifications → `available_commands_update`, `agent_thought_chunk`, + `agent_message_chunk`, `tool_call`, `tool_call_update` (with `rawOutput`), plus an extra + `_meta.field_meta.openhands.dev/metrics` block that the runtime ignores gracefully. +- Real tool execution: with the permission granted, the agent ran `cat test.txt` (a + `TerminalAction`), streamed `tool_call`/`tool_call_update`, and the prompt resolved with + `{"result":{"stopReason":"end_turn"}}`. + +**Two upstream bugs found while verifying:** + +1. `openhands acp --override-with-envs` is a **no-op in ACP mode**: `entrypoint.py` parses the + flag but never passes it to `run_acp_server`, so the LLM config always comes from + `~/.openhands/agent_settings.json` (or `OPENHANDS_PERSISTENCE_DIR`). The driver does not rely + on this flag, so no driver change is needed — but anyone expecting env-var LLM overrides in ACP + mode will silently get the on-disk config. +2. For an OpenAI-compatible endpoint (Ollama), the `model` in `agent_settings.json` needs a + litellm provider prefix (`openai/qwen3.5:latest`, not `qwen3.5:latest`); un-prefixed model + names fail with `litellm.BadRequestError: LLM Provider NOT provided`. + +**Earlier "hang" root-caused (not an OpenHands bug):** the manual probes that appeared to hang at +startup were deadlocking on a full stderr pipe — the probe never drained stderr while OpenHands +wrote its startup banner + SDK warning, so the child blocked on `write(2)` before answering +`initialize`. The driver is not affected: `AcpSessionRuntime.ts` drains stderr in a forked fiber +(`child.stderr.pipe(Stream.decodeText(), ...)`). + +**Remaining unverified (low risk):** + - Whether a local install genuinely never needs `authenticate` in all configurations (e.g. if a user has no `~/.openhands` credentials configured yet) — the driver assumes "already authenticated or fails visibly," consistent with `OpenHandsProvider.ts` always reporting `"unknown"` auth state rather than guessing. +- A live `session/resume` (loadSession) round-trip — the `loadSession: true` capability is + advertised and the shared runtime implements it, but it was not exercised in this session. ## Open questions -- Confirm the real shape of OpenHands' `session/update` notifications (tool call granularity, - permission option ids) against a live, credentialed session once available, and adjust - `OpenHandsAdapter.ts` if it diverges from the Grok/Cursor-derived assumptions. - Track the upstream `openhands-acp` entry-point bug and switch `OpenHandsAcpSupport.ts` back to spawning the dedicated binary once fixed upstream (both should be equivalent once fixed, but the dedicated binary keeps the process name and lifecycle distinct from other `openhands` CLI usage). - Decide whether `binaryPath` should default-resolve through the same `uv tool` install location T3 config expects for other CLI-shelling providers, or whether `PATH` resolution (current behavior, matching Grok/Cursor) is sufficient. +- The LLM backend for real T3 use is unresolved: `~/.openhands/agent_settings.json` currently + points at the headroom proxy with a stale, revoked Claude Code token. Refreshing that token (or + pointing OpenHands at Ollama) is tracked in the shared-memory backlog, not this repo. diff --git a/apps/server/src/provider/Layers/OpenHandsProvider.ts b/apps/server/src/provider/Layers/OpenHandsProvider.ts index 69c52c412049..ea0d629dcab8 100644 --- a/apps/server/src/provider/Layers/OpenHandsProvider.ts +++ b/apps/server/src/provider/Layers/OpenHandsProvider.ts @@ -53,9 +53,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const VERSION_PROBE_TIMEOUT_MS = 4_000; -// `initialize` is a single local round trip, so this is generous even on slow machines. -const OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS = 8_000; +// OpenHands is a Python CLI: cold `--version` takes ~4s on this machine (uv +// tool install, heavy imports), so 4s was intermittently timing out. 15s is +// generous for cold caches while still failing fast on a broken install. +const VERSION_PROBE_TIMEOUT_MS = 15_000; +// `initialize` spawns a fresh `openhands acp` (another ~4s Python startup) +// before the handshake, so this must cover process boot plus the round trip. +const OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS = 15_000; const OPENHANDS_BUILT_IN_MODELS = [ { From 9ed92efe27c61a2957c376102c583c372620ed13 Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 15 Sep 2026 11:15:55 -0600 Subject: [PATCH 10/11] fix(acp): decode standards-compliant JSON-RPC errors instead of crashing effect/rpc's ndjson codec only recognizes its own _tag:"Cause" marker on a JSON-RPC error as a typed failure; a standards-compliant agent's plain {code, message, data} error gets boxed as an opaque Die. Native Agent RPC responses skip this module's own request/response handling, so that Die reaches @effect/rpc's generic Schema.Defect() decoder and crashes as "Internal error at decodeJsonError" instead of surfacing the RPC's typed error schema. repairJsonRpcErrorExit rewrites a Die whose defect is a protocol error into a Fail while the defect is still the untouched raw object, letting it decode against the RPC's error schema like any other typed failure. Verified end-to-end through the real UI: enabled the OpenHands provider, selected it in the composer, sent a prompt, and got a clean response. Also registers the OpenHands provider in the settings UI (icon, provider meta) so it can be enabled from Settings > Providers. --- apps/web/src/components/Icons.tsx | 4 +++ .../src/components/chat/providerIconUtils.ts | 2 ++ .../components/settings/providerDriverMeta.ts | 9 ++++++ packages/effect-acp/src/protocol.ts | 29 ++++++++++++++++++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 199d0ba834d0..80464a411113 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,4 +1,5 @@ import React, { type SVGProps, useId } from "react"; +import { Bot } from "lucide-react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -655,6 +656,9 @@ export const AntigravityIcon: Icon = (props) => ( ); +// ponytail: generic placeholder (no real OpenHands brand asset on hand) — swap for the actual mark when available. +export const OpenHandsIcon: Icon = (props) => ; + export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..60e97b05ab6b 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -7,6 +7,7 @@ import { Icon, OpenAI, OpenCodeIcon, + OpenHandsIcon, } from "../Icons"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -16,6 +17,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, + [ProviderDriverKind.make("openhands")]: OpenHandsIcon, }; export type ModelEsque = { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..bf0cc9814dab 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -5,6 +5,7 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, + OpenHandsSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; @@ -16,6 +17,7 @@ import { type Icon, OpenAI, OpenCodeIcon, + OpenHandsIcon, } from "../Icons"; type ProviderSettingsSchema = { @@ -82,6 +84,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: AntigravityIcon, settingsSchema: AntigravitySettings, }, + { + value: ProviderDriverKind.make("openhands"), + label: "OpenHands", + icon: OpenHandsIcon, + badgeLabel: "Early Access", + settingsSchema: OpenHandsSettings, + }, ]; const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index cd6043f2db00..7ba9deb23f78 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -377,7 +377,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.flatMap((pending) => { const pendingRequest = pending.get(String(message.requestId)); if (!pendingRequest) { - return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + return Queue.offer(clientQueue, repairJsonRpcErrorExit(message)).pipe(Effect.asVoid); } if (message.exit._tag === "Success") { return completeExtPendingSuccess(message.requestId, message.exit.value); @@ -626,3 +626,30 @@ function isProtocolError( typeof value.message === "string" ); } + +/** + * effect/rpc's ndjson codec only recognizes its own `_tag: "Cause"` marker on a + * JSON-RPC `error` as a typed failure; any other JSON-RPC error (i.e. one from a + * standards-compliant, non-Effect ACP agent) gets boxed as an opaque `Die` + * (see effect/unstable/rpc/RpcSerialization.js). Native Agent RPC responses + * (no `pendingRequest` tracked in `extPending`) skip this module's own + * request/response handling entirely, so that Die reaches `@effect/rpc`'s + * generic `Schema.Defect()` decoder and crashes instead of surfacing as the + * RPC's typed `error` schema. Rewriting the Die into a `Fail` here, while the + * defect is still the untouched raw object, lets it decode against the RPC's + * error schema like any other typed failure. + */ +function repairJsonRpcErrorExit( + message: RpcMessage.ResponseExitEncoded, +): RpcMessage.ResponseExitEncoded { + if (message.exit._tag !== "Failure") return message; + let changed = false; + const cause = message.exit.cause.map((entry) => { + if (entry._tag === "Die" && isProtocolError(entry.defect)) { + changed = true; + return { _tag: "Fail" as const, error: entry.defect }; + } + return entry; + }); + return changed ? { ...message, exit: { ...message.exit, cause } } : message; +} From c346c9fb75e31323a1cea34b5794913a03d32e6b Mon Sep 17 00:00:00 2001 From: Jonah Paul Simon Date: Tue, 15 Sep 2026 11:49:08 -0600 Subject: [PATCH 11/11] scope PR to the JSON-RPC decode fix; drop OpenHands integration Macroscope flagged 5 blocking correctness issues and CodeRabbit flagged 4 actionable issues, all in the OpenHands provider/adapter/text-generation code that rode along with the bug fix. Pulling that out for its own PR resolves every open comment and leaves this PR as the standards-compliant ACP error decode fix it should have been. Also adds a regression test for repairJsonRpcErrorExit, which had no direct coverage. Co-Authored-By: Claude Sonnet 5 --- SUMMARY.md | 148 --- .../provider/Drivers/OpenHandsDriver.test.ts | 89 -- .../src/provider/Drivers/OpenHandsDriver.ts | 153 --- .../provider/Layers/OpenHandsAdapter.test.ts | 258 ----- .../src/provider/Layers/OpenHandsAdapter.ts | 960 ------------------ .../provider/Layers/OpenHandsProvider.test.ts | 150 --- .../src/provider/Layers/OpenHandsProvider.ts | 309 ------ .../provider/Layers/ProviderRegistry.test.ts | 1 - .../src/provider/Services/OpenHandsAdapter.ts | 17 - .../src/provider/acp/AcpSessionRuntime.ts | 27 +- .../provider/acp/OpenHandsAcpSupport.test.ts | 90 -- .../src/provider/acp/OpenHandsAcpSupport.ts | 143 --- apps/server/src/provider/builtInDrivers.ts | 3 - .../OpenHandsTextGeneration.test.ts | 220 ---- .../textGeneration/OpenHandsTextGeneration.ts | 253 ----- apps/web/src/components/Icons.tsx | 4 - .../src/components/chat/providerIconUtils.ts | 2 - .../components/settings/providerDriverMeta.ts | 9 - packages/contracts/src/model.ts | 9 - packages/contracts/src/settings.ts | 34 - packages/effect-acp/src/protocol.test.ts | 39 + 21 files changed, 48 insertions(+), 2870 deletions(-) delete mode 100644 SUMMARY.md delete mode 100644 apps/server/src/provider/Drivers/OpenHandsDriver.test.ts delete mode 100644 apps/server/src/provider/Drivers/OpenHandsDriver.ts delete mode 100644 apps/server/src/provider/Layers/OpenHandsAdapter.test.ts delete mode 100644 apps/server/src/provider/Layers/OpenHandsAdapter.ts delete mode 100644 apps/server/src/provider/Layers/OpenHandsProvider.test.ts delete mode 100644 apps/server/src/provider/Layers/OpenHandsProvider.ts delete mode 100644 apps/server/src/provider/Services/OpenHandsAdapter.ts delete mode 100644 apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts delete mode 100644 apps/server/src/provider/acp/OpenHandsAcpSupport.ts delete mode 100644 apps/server/src/textGeneration/OpenHandsTextGeneration.test.ts delete mode 100644 apps/server/src/textGeneration/OpenHandsTextGeneration.ts diff --git a/SUMMARY.md b/SUMMARY.md deleted file mode 100644 index e5ef2dff7804..000000000000 --- a/SUMMARY.md +++ /dev/null @@ -1,148 +0,0 @@ -# OpenHands provider driver - -Adds OpenHands (github.com/All-Hands-AI/OpenHands) as a built-in ACP-based provider driver, -following the Grok/Cursor pattern documented in `docs/internals/providers.md` and the 3-step -recipe in `apps/server/src/provider/builtInDrivers.ts`. - -## What was implemented - -- `packages/contracts/src/settings.ts` — `OpenHandsSettings` schema (`enabled`, `binaryPath`, - `customModels`), same shape as `GrokSettings`/`CursorSettings`. No invented fields. -- `packages/contracts/src/model.ts` — `OPENHANDS_DRIVER_KIND`, `OPENHANDS_DEFAULT_MODEL` sentinel - model, mirroring the Antigravity pattern (OpenHands has no `models` listing command). -- `apps/server/src/provider/acp/OpenHandsAcpSupport.ts` — spawn/runtime wiring, modeled on - `GrokAcpSupport.ts`. Builds the spawn command/args/env and constructs the shared - `AcpSessionRuntime`. Maps T3 `RuntimeMode` to OpenHands' `--llm-approve`/`--always-approve` CLI - flags and ACP confirmation-mode ids (`always-ask`/`llm-approve`/`always-approve`). -- `apps/server/src/provider/Services/OpenHandsAdapter.ts` + - `apps/server/src/provider/Layers/OpenHandsAdapter.ts` — the `ProviderAdapter` implementation - translating OpenHands' ACP session events (tool calls, permission requests, mode changes) into - T3 orchestration events, modeled on `CursorAdapter`/`GrokAdapter`. -- `apps/server/src/provider/Layers/OpenHandsProvider.ts` — status probing (`openhands --version`) - and `ServerProvider` snapshot building, modeled on `GrokProvider.ts`. No model catalog probe - (none exists) and no auth-state guess (OpenHands resolves LLM credentials from `~/.openhands` - out of band, not via CLI login, so auth always reports `"unknown"`). -- `apps/server/src/textGeneration/OpenHandsTextGeneration.ts` — headless text-generation helper - (tools disabled, always-ask mode), modeled on the Grok/Cursor text-generation helpers. -- `apps/server/src/provider/Drivers/OpenHandsDriver.ts` — the `ProviderDriver` bundling the above - (driverKind, metadata, configSchema, defaultConfig, create), modeled on `GrokDriver.ts`. -- `apps/server/src/provider/builtInDrivers.ts` — registered `OpenHandsDriver`: import, - `BuiltInDriversEnv` union entry, `BUILT_IN_DRIVERS` array entry. This is the only change to a - shared registry file. -- `apps/server/src/provider/acp/AcpSessionRuntime.ts` — one small, backward-compatible change: - `authMethodId` is now optional (`readonly authMethodId?: string`), and the `authenticate` RPC - call is skipped when it's omitted. This was necessary, not incidental: ACP's `authenticate` is - not optional per the protocol, but OpenHands only advertises an interactive cloud OAuth - device-flow auth method. A local install is already authenticated out of band via `~/.openhands` - credentials, so sending `authenticate` on every session would either be rejected or would open a - browser login each time. All existing callers (Grok, Cursor, Antigravity) still pass - `authMethodId` explicitly, so this is additive and doesn't change their behavior — confirmed via - `grep` across `apps/server/src/provider/` and the full test suite. -- Unit tests alongside every new file (`*.test.ts`), following the existing pattern next to - `GrokDriver.ts`/`GrokAcpSupport.ts`: `OpenHandsDriver.test.ts`, `OpenHandsAdapter.test.ts`, - `OpenHandsProvider.test.ts`, `OpenHandsAcpSupport.test.ts`, `OpenHandsTextGeneration.test.ts`. - Existing `ProviderRegistry.test.ts` updated to include the new driver in the registry-wide - assertions. - -## The `openhands-acp` binary investigation - -The locally installed CLI (`openhands` 1.16.0 via `uv tool install openhands`) ships an -`openhands-acp` console script that is broken: - -``` -ModuleNotFoundError: No module named 'openhands_cli.acp' -``` - -Inspecting `~/.local/share/uv/tools/openhands/`: - -- The generated entry point (`~/.local/share/uv/tools/openhands/bin/openhands-acp`) targets - `openhands_cli.acp:main`. -- The installed wheel only contains `openhands_cli.acp_impl`, not a top-level `openhands_cli.acp` - module — an upstream packaging bug where the console-script entry point and the actual module - layout diverged (visible comparing the wheel's `dist-info` entry_points to the on-disk package - tree). -- `openhands acp` (the CLI's own `acp` subcommand, not the separate `openhands-acp` binary) runs - the same ACP server code (imports the working `openhands_cli.acp_impl` internally) and does not - hit the broken import path. - -**Workaround used**: spawn `openhands acp` instead of the separate `openhands-acp` binary. This is -implemented in `OpenHandsAcpSupport.ts` with a doc comment explaining why, and a note to switch -back to the dedicated binary once the upstream entry point is fixed. - -## Verified vs. unverified - -**Verified:** - -- `pnpm run typecheck` in `apps/server` — passes, 0 errors, nothing OpenHands-related flagged. -- Full test suite (`vp test run`, which ran the whole `apps/server` suite rather than just the - filtered files) — 298 test files passed (2 skipped), 4285 tests passed (10 skipped), 0 - failures. All new OpenHands unit tests are part of this run and pass. -- `openhands acp --help` runs successfully (confirms the subcommand exists and the workaround is - viable at all). - -**Verified live end-to-end (2026-09-08, against the installed `openhands` 1.16.0):** - -A full ACP conversation turn was driven over stdio against a real, credentialed OpenHands agent -(LLM = local Ollama, `gemma-4-12B-it-qat`, via `OPENHANDS_PERSISTENCE_DIR` pointing at an isolated -config). Every message shape the driver assumes was confirmed against live output: - -- `initialize` → `agentCapabilities` (`loadSession: true`, `mcpCapabilities` http+sse, - `promptCapabilities` audio/embeddedContext/image), `agentInfo` "OpenHands CLI ACP Agent" - 1.16.0, `authMethods` = only `[oauth]`. Confirms the driver's skip-`authenticate` decision. -- `session/new` → `sessionId` + `modes.availableModes` with ids exactly - `always-ask`/`llm-approve`/`always-approve` and `currentModeId: "always-ask"` — matches - `OPENHANDS_ALWAYS_ASK_MODE_ID`/`openHandsAcpModeId` and the `--llm-approve`/`--always-approve` - spawn flags. -- `session/prompt` → `prompt` must be a **list of content blocks** (`[{type:"text",text:...}]`), - not a string. The driver already sends a list; a string prompt returns - `-32602 Invalid params (list_type)`. -- `session/request_permission` notification → `{options:[{kind,optionId,name}...], sessionId, -toolCall:{...}}` with kinds `allow_once`/`reject_once`/`allow_always`. The driver's - `selectPermissionOptionId` lookup by kind resolves correctly (`accept`→`allow_once`, - `acceptAlways`→`allow_always`, `reject`→`reject_once`), and `parsePermissionRequest` reads - exactly the `toolCall` fields OpenHands sends. Responding with - `{outcome:{outcome:"selected",optionId:"accept"}}` completes the approval. -- `session/update` notifications → `available_commands_update`, `agent_thought_chunk`, - `agent_message_chunk`, `tool_call`, `tool_call_update` (with `rawOutput`), plus an extra - `_meta.field_meta.openhands.dev/metrics` block that the runtime ignores gracefully. -- Real tool execution: with the permission granted, the agent ran `cat test.txt` (a - `TerminalAction`), streamed `tool_call`/`tool_call_update`, and the prompt resolved with - `{"result":{"stopReason":"end_turn"}}`. - -**Two upstream bugs found while verifying:** - -1. `openhands acp --override-with-envs` is a **no-op in ACP mode**: `entrypoint.py` parses the - flag but never passes it to `run_acp_server`, so the LLM config always comes from - `~/.openhands/agent_settings.json` (or `OPENHANDS_PERSISTENCE_DIR`). The driver does not rely - on this flag, so no driver change is needed — but anyone expecting env-var LLM overrides in ACP - mode will silently get the on-disk config. -2. For an OpenAI-compatible endpoint (Ollama), the `model` in `agent_settings.json` needs a - litellm provider prefix (`openai/qwen3.5:latest`, not `qwen3.5:latest`); un-prefixed model - names fail with `litellm.BadRequestError: LLM Provider NOT provided`. - -**Earlier "hang" root-caused (not an OpenHands bug):** the manual probes that appeared to hang at -startup were deadlocking on a full stderr pipe — the probe never drained stderr while OpenHands -wrote its startup banner + SDK warning, so the child blocked on `write(2)` before answering -`initialize`. The driver is not affected: `AcpSessionRuntime.ts` drains stderr in a forked fiber -(`child.stderr.pipe(Stream.decodeText(), ...)`). - -**Remaining unverified (low risk):** - -- Whether a local install genuinely never needs `authenticate` in all configurations (e.g. if a - user has no `~/.openhands` credentials configured yet) — the driver assumes "already - authenticated or fails visibly," consistent with `OpenHandsProvider.ts` always reporting - `"unknown"` auth state rather than guessing. -- A live `session/resume` (loadSession) round-trip — the `loadSession: true` capability is - advertised and the shared runtime implements it, but it was not exercised in this session. - -## Open questions - -- Track the upstream `openhands-acp` entry-point bug and switch `OpenHandsAcpSupport.ts` back to - spawning the dedicated binary once fixed upstream (both should be equivalent once fixed, but the - dedicated binary keeps the process name and lifecycle distinct from other `openhands` CLI usage). -- Decide whether `binaryPath` should default-resolve through the same `uv tool` install location - T3 config expects for other CLI-shelling providers, or whether `PATH` resolution (current - behavior, matching Grok/Cursor) is sufficient. -- The LLM backend for real T3 use is unresolved: `~/.openhands/agent_settings.json` currently - points at the headroom proxy with a stale, revoked Claude Code token. Refreshing that token (or - pointing OpenHands at Ollama) is tracked in the shared-memory backlog, not this repo. diff --git a/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts b/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts deleted file mode 100644 index 3294f71aae97..000000000000 --- a/apps/server/src/provider/Drivers/OpenHandsDriver.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodePath from "node:path"; -import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/unstable/http"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; -import { OpenHandsDriver } from "./OpenHandsDriver.ts"; - -const testLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-openhands-driver-", -}).pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ), - Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), - Layer.provideMerge( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("Disabled OpenHands must not make an HTTP request")), - ), - ), -); - -it.layer(testLayer)("OpenHandsDriver", (it) => { - it.effect('disabled instance reports status "disabled" and never spawns a process', () => - Effect.gen(function* () { - const instance = yield* OpenHandsDriver.create({ - instanceId: ProviderInstanceId.make("openhands-disabled"), - displayName: "OpenHands test", - enabled: false, - environment: [], - config: OpenHandsDriver.defaultConfig(), - }); - expect((yield* instance.snapshot.refresh).status).toBe("disabled"); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("Disabled OpenHands must not spawn a process")), - ), - Effect.scoped, - ), - ); - - // Unlike Cursor and Codex, OpenHands has no proven update-installer path (it - // ships via `uv tool install`), so maintenance is manual-only regardless of - // whether the configured executable exists. - it.effect("stays manual-only regardless of the configured executable", () => - Effect.gen(function* () { - const instance = yield* OpenHandsDriver.create({ - instanceId: ProviderInstanceId.make("openhands-manual-only"), - displayName: "OpenHands test", - enabled: false, - environment: [], - config: { - ...OpenHandsDriver.defaultConfig(), - binaryPath: NodePath.join("does", "not", "exist", "openhands"), - }, - }); - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.die("OpenHands must not spawn a process to resolve maintenance"), - ), - ), - Effect.scoped, - ), - ); - - it("default config is disabled with the bare `openhands` binary", () => { - const config = OpenHandsDriver.defaultConfig(); - expect(config.enabled).toBe(false); - expect(config.binaryPath).toBe("openhands"); - expect(config.customModels).toEqual([]); - }); -}); diff --git a/apps/server/src/provider/Drivers/OpenHandsDriver.ts b/apps/server/src/provider/Drivers/OpenHandsDriver.ts deleted file mode 100644 index ba415f79ab2d..000000000000 --- a/apps/server/src/provider/Drivers/OpenHandsDriver.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * OpenHandsDriver — `ProviderDriver` for the OpenHands CLI (`openhands acp`). - * - * OpenHands exposes an ACP-based CLI, like Grok and Cursor. Unlike either, it has no - * model catalog to refresh (its ACP session advertises no model state) and no proven - * update-installer path (it ships via `uv tool install`, which `providerMaintenance.ts` - * cannot attribute to a specific owner), so this driver skips catalog refresh and uses - * manual-only maintenance capabilities. See `../acp/OpenHandsAcpSupport.ts` for the - * `openhands-acp` binary breakage this driver works around. - * - * @module provider/Drivers/OpenHandsDriver - */ -import { OpenHandsSettings, ProviderDriverKind } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { makeOpenHandsTextGeneration } from "../../textGeneration/OpenHandsTextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { makeOpenHandsAdapter } from "../Layers/OpenHandsAdapter.ts"; -import { - buildInitialOpenHandsProviderSnapshot, - checkOpenHandsProviderStatus, - enrichOpenHandsSnapshot, -} from "../Layers/OpenHandsProvider.ts"; -import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { - defaultProviderContinuationIdentity, - type ProviderDriver, - type ProviderInstance, -} from "../ProviderDriver.ts"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -import { - haveProviderSnapshotSettingsChanged, - makeProviderSnapshotSettingsSource, - type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; - -const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); - -const DRIVER_KIND = ProviderDriverKind.make("openhands"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, -}); - -export type OpenHandsDriverEnv = - | BackgroundPolicy.BackgroundPolicy - | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto - | FileSystem.FileSystem - | HttpClient.HttpClient - | Path.Path - | ProviderEventLoggers - | ServerConfig - | ServerSettingsService; - -export const OpenHandsDriver: ProviderDriver = { - driverKind: DRIVER_KIND, - metadata: { - displayName: "OpenHands", - supportsMultipleInstances: true, - }, - configSchema: OpenHandsSettings, - defaultConfig: (): OpenHandsSettings => decodeOpenHandsSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies OpenHandsSettings; - const adapter = yield* makeOpenHandsAdapter(effectiveConfig, { - environment: processEnv, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - instanceId, - }); - const textGeneration = yield* makeOpenHandsTextGeneration(effectiveConfig, processEnv); - - const checkProvider = checkOpenHandsProviderStatus(effectiveConfig, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); - const snapshot = yield* makeManagedServerProvider< - ProviderSnapshotSettings - >({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialOpenHandsProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichOpenHandsSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities: MAINTENANCE_CAPABILITIES, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, - }), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build OpenHands snapshot: ${cause.message ?? String(cause)}`, - cause, - }), - ), - ); - - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - snapshotForCwd: () => snapshot.getSnapshot, - adapter, - textGeneration, - } satisfies ProviderInstance; - }), -}; diff --git a/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts b/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts deleted file mode 100644 index 47003ca18082..000000000000 --- a/apps/server/src/provider/Layers/OpenHandsAdapter.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; -import * as NodeOS from "node:os"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeURL from "node:url"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import { - OpenHandsSettings, - ProviderInstanceId, - ThreadId, - type ProviderRuntimeEvent, -} from "@t3tools/contracts"; - -import type { AcpSessionModeState } from "../acp/AcpRuntimeModel.ts"; -import { ServerConfig } from "../../config.ts"; -import { - makeOpenHandsAdapter, - parseOpenHandsResume, - resolveRequestedModeId, - selectPermissionOptionId, -} from "./OpenHandsAdapter.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; - -const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); - -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); - -async function makeMockOpenHandsWrapper(extraEnv?: Record) { - const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "openhands-acp-mock-")); - return writeFakeCli({ - directory: dir, - name: "fake-openhands", - env: extraEnv ?? {}, - // Real spawns pass `acp` plus a mode flag (`--llm-approve`, `--always-approve`); - // only the subcommand is asserted since the flag varies with runtimeMode. - source: execScriptSource({ scriptPath: mockAgentPath, expectedArgs: ["acp"] }), - }); -} - -const openHandsAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-openhands-adapter-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); - -const makeTestAdapter = ( - binaryPath: string, - options?: Parameters[1], -) => makeOpenHandsAdapter(decodeOpenHandsSettings({ binaryPath }), options).pipe(Effect.orDie); - -it("accepts a resume cursor only when its schema version and sessionId match", () => { - assert.deepEqual(parseOpenHandsResume({ schemaVersion: 1, sessionId: "abc" }), { - sessionId: "abc", - }); - assert.isUndefined(parseOpenHandsResume(undefined)); - assert.isUndefined(parseOpenHandsResume(null)); - assert.isUndefined(parseOpenHandsResume("abc")); - assert.isUndefined(parseOpenHandsResume({ schemaVersion: 2, sessionId: "abc" })); - assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1, sessionId: "" })); - assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1, sessionId: " " })); - assert.isUndefined(parseOpenHandsResume({ schemaVersion: 1 })); -}); - -function openHandsModeState(availableModeIds: ReadonlyArray): AcpSessionModeState { - return { - currentModeId: "always-ask", - availableModes: availableModeIds.map((id) => ({ id, name: id })), - }; -} - -it("resolves undefined without mode state, since OpenHands has nothing to switch", () => { - assert.isUndefined( - resolveRequestedModeId({ - interactionMode: "default", - runtimeMode: "full-access", - modeState: undefined, - }), - ); -}); - -it("maps runtime mode to the matching OpenHands confirmation mode", () => { - const modeState = openHandsModeState(["always-ask", "llm-approve", "always-approve"]); - assert.equal( - resolveRequestedModeId({ - interactionMode: "default", - runtimeMode: "approval-required", - modeState, - }), - "always-ask", - ); - assert.equal( - resolveRequestedModeId({ interactionMode: "default", runtimeMode: "auto", modeState }), - "llm-approve", - ); - assert.equal( - resolveRequestedModeId({ - interactionMode: "default", - runtimeMode: "auto-accept-edits", - modeState, - }), - "llm-approve", - ); - assert.equal( - resolveRequestedModeId({ interactionMode: "default", runtimeMode: "full-access", modeState }), - "always-approve", - ); -}); - -it("forces always-ask for plan mode regardless of runtime mode", () => { - const modeState = openHandsModeState(["always-ask", "llm-approve", "always-approve"]); - assert.equal( - resolveRequestedModeId({ interactionMode: "plan", runtimeMode: "full-access", modeState }), - "always-ask", - ); -}); - -it("falls back to the agent's current mode when the requested mode isn't offered", () => { - const modeState = openHandsModeState(["always-ask"]); - assert.equal( - resolveRequestedModeId({ interactionMode: "default", runtimeMode: "full-access", modeState }), - "always-ask", - ); -}); - -function openHandsPermissionRequest( - options: ReadonlyArray<{ - readonly optionId: string; - readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; - }>, -) { - return { - sessionId: "mock-session-1", - toolCall: { - toolCallId: "tool-call-1", - title: "cat package.json", - kind: "execute" as const, - status: "pending" as const, - }, - options: options.map((option) => ({ - optionId: option.optionId, - name: option.kind, - kind: option.kind, - })), - }; -} - -it("maps accept decisions to allow_once, preferring it over allow_always", () => { - const request = openHandsPermissionRequest([ - { optionId: "allow-once", kind: "allow_once" }, - { optionId: "allow-always", kind: "allow_always" }, - { optionId: "reject-once", kind: "reject_once" }, - ]); - assert.equal(selectPermissionOptionId(request, "accept"), "allow-once"); -}); - -it("maps acceptForSession and acceptAlways decisions to allow_always when offered", () => { - const request = openHandsPermissionRequest([ - { optionId: "allow-once", kind: "allow_once" }, - { optionId: "allow-always", kind: "allow_always" }, - { optionId: "reject-once", kind: "reject_once" }, - ]); - assert.equal(selectPermissionOptionId(request, "acceptForSession"), "allow-always"); - assert.equal(selectPermissionOptionId(request, "acceptAlways"), "allow-always"); -}); - -it("falls back to allow_once when OpenHands omits allow_always", () => { - const request = openHandsPermissionRequest([ - { optionId: "allow-once", kind: "allow_once" }, - { optionId: "reject-once", kind: "reject_once" }, - ]); - assert.equal(selectPermissionOptionId(request, "acceptForSession"), "allow-once"); -}); - -it("maps decline to reject_once", () => { - const request = openHandsPermissionRequest([ - { optionId: "allow-once", kind: "allow_once" }, - { optionId: "reject-once", kind: "reject_once" }, - ]); - assert.equal(selectPermissionOptionId(request, "decline"), "reject-once"); -}); - -it("returns undefined when no option matches the decision's kinds", () => { - const request = openHandsPermissionRequest([{ optionId: "allow-once", kind: "allow_once" }]); - assert.isUndefined(selectPermissionOptionId(request, "decline")); -}); - -it.layer(openHandsAdapterTestLayer)("OpenHandsAdapterLive", (it) => { - it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => - Effect.gen(function* () { - const threadId = ThreadId.make("openhands-mock-thread"); - const wrapperPath = yield* Effect.promise(() => makeMockOpenHandsWrapper()); - const adapter = yield* makeTestAdapter(wrapperPath); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const turnCompleted = yield* Deferred.make(); - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }).pipe( - Effect.andThen( - event.type === "turn.completed" - ? Deferred.succeed(turnCompleted, undefined) - : Effect.void, - ), - ), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId, - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { instanceId: ProviderInstanceId.make("openhands"), model: "openhands" }, - }); - - assert.equal(session.provider, "openhands"); - assert.deepStrictEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "mock-session-1", - }); - - yield* adapter.sendTurn({ - threadId, - input: "hello openhands", - attachments: [], - }); - - yield* Deferred.await(turnCompleted); - yield* Fiber.interrupt(runtimeEventsFiber); - const types = runtimeEvents.map((e) => e.type); - - assert.includeMembers(types, [ - "session.started", - "session.state.changed", - "thread.started", - "turn.started", - "item.started", - "content.delta", - "turn.completed", - ] as const); - - const delta = runtimeEvents.find((e) => e.type === "content.delta"); - assert.isDefined(delta); - if (delta?.type === "content.delta") { - assert.equal(delta.payload.delta, "hello from mock"); - } - - yield* adapter.stopSession(threadId); - }), - ); -}); diff --git a/apps/server/src/provider/Layers/OpenHandsAdapter.ts b/apps/server/src/provider/Layers/OpenHandsAdapter.ts deleted file mode 100644 index 6310d82071e7..000000000000 --- a/apps/server/src/provider/Layers/OpenHandsAdapter.ts +++ /dev/null @@ -1,960 +0,0 @@ -/** - * OpenHandsAdapterLive — OpenHands CLI (`openhands acp`) via ACP. - * - * @module OpenHandsAdapterLive - */ - -import { - ApprovalRequestId, - EventId, - type OpenHandsSettings, - type ProviderApprovalDecision, - type ProviderInteractionMode, - type ProviderRuntimeEvent, - type ProviderSession, - ProviderDriverKind, - ProviderInstanceId, - RuntimeRequestId, - type RuntimeMode, - type ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PubSub from "effect/PubSub"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/schema"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, -} from "../Errors.ts"; -import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "../acp/AcpCoreRuntimeEvents.ts"; -import { type AcpSessionModeState, parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; -import { - OPENHANDS_ALWAYS_ASK_MODE_ID, - makeOpenHandsAcpRuntime, - openHandsAcpModeId, - resolveOpenHandsAcpBaseModelId, -} from "../acp/OpenHandsAcpSupport.ts"; -import { type OpenHandsAdapterShape } from "../Services/OpenHandsAdapter.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); - -const PROVIDER = ProviderDriverKind.make("openhands"); -const OPENHANDS_RESUME_VERSION = 1 as const; - -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; -} - -export interface OpenHandsAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; - /** - * Selections are honored when `modelSelection.instanceId` matches this value. - * Defaults to the legacy built-in instance id (`openhands`). - */ - readonly instanceId?: ProviderInstanceId; - /** - * Optional per-session settings resolver. When provided the adapter yields - * this effect at the start of every session and uses the result instead of - * the `openHandsSettings` captured at construction. Production instances - * leave this undefined; test suites that mutate `ServerSettingsService` - * mid-flight pass a resolver that reads the latest snapshot. - */ - readonly resolveSettings?: Effect.Effect; -} - -interface PendingApproval { - readonly decision: Deferred.Deferred; - readonly kind: string | "unknown"; -} - -interface OpenHandsSessionContext { - readonly threadId: ThreadId; - session: ProviderSession; - readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - /** Number of sendTurn prompts currently in flight or being prepared. - * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ - promptsInFlight: number; - stopped: boolean; -} - -function settlePendingApprovalsAsCancelled( - pendingApprovals: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingApprovals.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { - discard: true, - }, - ); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function parseOpenHandsResume(raw: unknown): { sessionId: string } | undefined { - if (!isRecord(raw)) return undefined; - if (raw.schemaVersion !== OPENHANDS_RESUME_VERSION) return undefined; - if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; - return { sessionId: raw.sessionId.trim() }; -} - -/** - * OpenHands names its confirmation modes `always-ask`, `llm-approve`, and - * `always-approve`, so the runtime mode maps onto a mode id directly. Plan is - * not one of them — the agent has no read-only mode — so a plan turn falls back - * to `always-ask` and every edit stays behind an approval. - */ -export function resolveRequestedModeId(input: { - readonly interactionMode: ProviderInteractionMode | undefined; - readonly runtimeMode: RuntimeMode; - readonly modeState: AcpSessionModeState | undefined; -}): string | undefined { - const modeState = input.modeState; - if (!modeState) { - return undefined; - } - const requested = - input.interactionMode === "plan" - ? OPENHANDS_ALWAYS_ASK_MODE_ID - : openHandsAcpModeId(input.runtimeMode); - return modeState.availableModes.some((mode) => mode.id === requested) - ? requested - : modeState.currentModeId; -} - -/** - * Resolves the option id OpenHands expects for a decision. The option ids are - * agent-defined (`accept`, `reject`, `always_proceed`), so they are looked up - * through the ACP `kind` instead of assumed. - */ -export function selectPermissionOptionId( - request: EffectAcpSchema.RequestPermissionRequest, - decision: Exclude, -): string | undefined { - const preferredKinds = - decision === "acceptAlways" || decision === "acceptForSession" - ? (["allow_always", "allow_once"] as const) - : decision === "accept" - ? (["allow_once", "allow_always"] as const) - : (["reject_once", "reject_always"] as const); - for (const kind of preferredKinds) { - const optionId = request.options.find((option) => option.kind === kind)?.optionId; - if (typeof optionId === "string" && optionId.trim()) { - return optionId.trim(); - } - } - return undefined; -} - -export function makeOpenHandsAdapter( - openHandsSettings: OpenHandsSettings, - options?: OpenHandsAdapterLiveOptions, -) { - return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("openhands"); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); - const crypto = yield* Crypto.Crypto; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); - - const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const runtimeEventPubSub = yield* PubSub.unbounded(); - - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "crypto/randomUUIDv4", - detail: "Failed to generate OpenHands runtime identifier.", - cause, - }), - ), - ); - const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - const mapHandlerFailure = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to process OpenHands ACP request.", - cause, - }), - ), - ); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const logNative = (threadId: ThreadId, method: string, payload: unknown) => - Effect.gen(function* () { - if (!nativeEventLogger) return; - const observedAt = yield* nowIso; - yield* nativeEventLogger.write( - { - observedAt, - event: { - id: yield* randomUUIDv4, - kind: "notification", - provider: PROVIDER, - createdAt: observedAt, - method, - threadId, - payload, - }, - }, - threadId, - ); - }); - - const emitPlanUpdate = ( - ctx: OpenHandsSessionContext, - payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }, - rawPayload: unknown, - ) => - Effect.gen(function* () { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; - if (ctx.lastPlanFingerprint === fingerprint) { - return; - } - ctx.lastPlanFingerprint = fingerprint; - yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload, - source: "acp.jsonrpc", - method: "session/update", - rawPayload, - }), - ); - }); - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), - ); - } - return Effect.succeed(ctx); - }; - - const stopSessionInternal = (ctx: OpenHandsSessionContext) => - Effect.gen(function* () { - if (ctx.stopped) return; - ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - if (ctx.notificationFiber) { - yield* Fiber.interrupt(ctx.notificationFiber); - } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); - sessions.delete(ctx.threadId); - yield* offerRuntimeEvent({ - type: "session.exited", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - payload: { exitKind: "graceful" }, - }); - }); - - const applyRequestedMode = (input: { - readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; - readonly threadId: ThreadId; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode | undefined; - }) => - Effect.gen(function* () { - const requestedModeId = resolveRequestedModeId({ - interactionMode: input.interactionMode, - runtimeMode: input.runtimeMode, - modeState: yield* input.runtime.getModeState, - }); - if (!requestedModeId) { - return; - } - yield* input.runtime - .setMode(requestedModeId) - .pipe( - Effect.mapError((cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), - ), - ); - }); - - const startSession: OpenHandsAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } - - const cwd = path.resolve(input.cwd.trim()); - const openHandsModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const pendingApprovals = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - let ctx!: OpenHandsSessionContext; - - const resumeSessionId = parseOpenHandsResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, - provider: PROVIDER, - threadId: input.threadId, - }); - - const effectiveOpenHandsSettings = options?.resolveSettings - ? yield* options.resolveSettings - : openHandsSettings; - - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeOpenHandsAcpRuntime({ - openHandsSettings: effectiveOpenHandsSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - runtimeMode: input.runtimeMode, - // OpenHands advertises `loadSession`, so a stored session id is - // replayed through `session/load` instead of starting over. - ...(resumeSessionId ? { resumeSessionId, resumeMethod: "load" as const } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - - const started = yield* Effect.gen(function* () { - yield* acp.handleRequestPermission((params) => - mapHandlerFailure( - Effect.gen(function* () { - yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectPermissionOptionId( - params, - "acceptForSession", - ); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - if (resolved === "cancel") { - return { outcome: { outcome: "cancelled" } as const }; - } - const optionId = selectPermissionOptionId(params, resolved); - // An agent that offers no option for the decision leaves - // cancellation as the only truthful answer. - return optionId === undefined - ? { outcome: { outcome: "cancelled" } as const } - : { outcome: { outcome: "selected" as const, optionId } }; - }), - ), - ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - yield* applyRequestedMode({ - runtime: acp, - threadId: input.threadId, - runtimeMode: input.runtimeMode, - interactionMode: undefined, - }); - - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: openHandsModelSelection?.model, - threadId: input.threadId, - resumeCursor: { - schemaVersion: OPENHANDS_RESUME_VERSION, - sessionId: started.sessionId, - }, - createdAt: now, - updatedAt: now, - }; - - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - promptsInFlight: 0, - stopped: false, - }; - - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - switch (event._tag) { - case "EventStreamBarrier": - yield* Deferred.succeed(event.acknowledge, undefined); - return; - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* emitPlanUpdate(ctx, event.payload, event.rawPayload); - return; - case "ToolCallUpdated": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process OpenHands runtime notification.", { cause }), - ), - // Fork into the session scope so the consumer outlives the - // `startSession` fiber; see CursorAdapter for the same trap. - Effect.forkIn(ctx.scope), - ); - - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; - - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "OpenHands ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); - - return session; - }).pipe(Effect.scoped), - ); - - const sendTurn: OpenHandsAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent folds - // the new prompt into the ongoing work, so the active turn id is - // reused instead of opening a new turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - ctx.promptsInFlight += 1; - - return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const resolvedModel = resolveOpenHandsAcpBaseModelId( - turnModelSelection?.model ?? ctx.session.model, - ); - yield* applyRequestedMode({ - runtime: ctx.acp, - threadId: input.threadId, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - }); - ctx.activeTurnId = turnId; - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: resolvedModel }, - }); - } - - const promptParts: Array = []; - const rawPrompt = input.input?.trim() ?? ""; - if (rawPrompt) { - promptParts.push({ type: "text", text: rawPrompt }); - } - if (input.attachments && input.attachments.length > 0) { - for (const attachment of input.attachments) { - // OpenHands advertises image prompt capability only. Generic - // files reach the agent through the path line ProviderService - // puts in the prompt. - if (attachment.type !== "image") { - continue; - } - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: cause.message, - cause, - }), - ), - ); - promptParts.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); - } - } - - if (promptParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", - }); - } - - // ACP has no system-message field; keep runtime context separate from the user's text. - const result = yield* ctx.acp - .prompt({ - prompt: [ - ...promptParts, - { - type: "text", - text: buildRuntimeInstructions({ harness: "OpenHands", model: resolvedModel }), - }, - ], - }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), - ), - ); - - yield* ctx.acp.drainEvents; - - const turnRecord = ctx.turns.find((turn) => turn.id === turnId); - if (turnRecord) { - turnRecord.items.push({ prompt: promptParts, result }); - } else { - ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolvedModel, - }; - - // Only the last remaining prompt settles the turn — a steer- - // superseded prompt resolving (usually cancelled) while another is - // in flight or pending must leave the merged turn running. - if (ctx.promptsInFlight === 1) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, - }, - }); - } - - return { - threadId: input.threadId, - turnId, - resumeCursor: ctx.session.resumeCursor, - }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); - }), - ), - ); - }); - - const interruptTurn: OpenHandsAdapterShape["interruptTurn"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); - }); - - const respondToRequest: OpenHandsAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingApprovals.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending approval request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.decision, decision); - }); - - // OpenHands has no structured user-input request over ACP; questions come - // back as ordinary assistant text. - const respondToUserInput: OpenHandsAdapterShape["respondToUserInput"] = (threadId, requestId) => - Effect.gen(function* () { - yield* requireSession(threadId); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/elicitation", - detail: `Unknown pending user-input request: ${requestId}`, - }); - }); - - const readThread: OpenHandsAdapterShape["readThread"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - return { threadId, turns: ctx.turns }; - }); - - const rollbackThread: OpenHandsAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }); - } - const nextLength = Math.max(0, ctx.turns.length - numTurns); - ctx.turns.splice(nextLength); - return { threadId, turns: ctx.turns }; - }); - - const stopSession: OpenHandsAdapterShape["stopSession"] = (threadId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); - }), - ); - - const listSessions: OpenHandsAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); - - const hasSession: OpenHandsAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const c = sessions.get(threadId); - return c !== undefined && !c.stopped; - }); - - const stopAll: OpenHandsAdapterShape["stopAll"] = () => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); - - yield* Effect.addFinalizer(() => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( - Effect.catch((cause) => - Effect.logError("Failed to emit OpenHands session shutdown event.", { cause }), - ), - Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), - Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), - ), - ); - - const streamEvents = Stream.fromPubSub(runtimeEventPubSub); - - return { - provider: PROVIDER, - // The OpenHands ACP agent negotiates its model out of band (`~/.openhands` - // config), so there is no in-session model configuration option. - capabilities: { sessionModelSwitch: "unsupported" }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - streamEvents, - } satisfies OpenHandsAdapterShape; - }); -} diff --git a/apps/server/src/provider/Layers/OpenHandsProvider.test.ts b/apps/server/src/provider/Layers/OpenHandsProvider.test.ts deleted file mode 100644 index 2136f11ce964..000000000000 --- a/apps/server/src/provider/Layers/OpenHandsProvider.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - resolves mock ACP agent script path relative to this test file. -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Schema from "effect/Schema"; -import { OpenHandsSettings } from "@t3tools/contracts"; - -import { - buildInitialOpenHandsProviderSnapshot, - checkOpenHandsProviderStatus, -} from "./OpenHandsProvider.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; - -const decodeOpenHandsSettings = Schema.decodeSync(OpenHandsSettings); -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); - -describe("buildInitialOpenHandsProviderSnapshot", () => { - it.effect("returns a disabled snapshot when settings.enabled is false", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialOpenHandsProviderSnapshot( - decodeOpenHandsSettings({ enabled: false }), - ); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - expect(snapshot.installed).toBe(false); - expect(snapshot.message).toContain("disabled"); - }), - ); - - it.effect("returns disabled by default — OpenHands is opt-in", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialOpenHandsProviderSnapshot(decodeOpenHandsSettings({})); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - }), - ); - - it.effect("returns a pending snapshot when enabled", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialOpenHandsProviderSnapshot( - decodeOpenHandsSettings({ enabled: true }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("warning"); - expect(snapshot.version).toBeNull(); - expect(snapshot.message).toContain("Checking OpenHands"); - }), - ); -}); - -it.layer(NodeServices.layer)("checkOpenHandsProviderStatus", (it) => { - it.effect("reports binary as missing when binary path does not resolve", () => - Effect.gen(function* () { - const snapshot = yield* checkOpenHandsProviderStatus( - decodeOpenHandsSettings({ - enabled: true, - binaryPath: "/definitely/not/installed/openhands-binary", - }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(false); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); - }), - ); - - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-openhands-version-" }); - const openHandsPath = writeFakeCli({ - directory: dir, - name: "openhands", - source: ["process.exit(2);"].join("\n"), - }); - return yield* checkOpenHandsProviderStatus( - decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), - ); - }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toBe("OpenHands CLI is installed but failed to run."); - }), - ); - - const writeFakeOpenHandsCli = (input: { readonly acp: boolean }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-openhands-probe-" }); - return writeFakeCli({ - directory: dir, - name: "openhands", - source: [ - 'if (process.argv[2] === "--version") {', - ' process.stdout.write("openhands 1.16.0\\n");', - " process.exit(0);", - "}", - 'if (process.argv[2] !== "acp") process.exit(1);', - ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), - "", - ].join("\n"), - }); - }); - - it.effect("reports ready when the ACP initialize probe succeeds", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const openHandsPath = yield* writeFakeOpenHandsCli({ acp: true }); - return yield* checkOpenHandsProviderStatus( - decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), - ); - }), - ); - - expect(snapshot.status).toBe("ready"); - expect(snapshot.installed).toBe(true); - expect(snapshot.version).toBe("1.16.0"); - expect(snapshot.auth).toEqual({ status: "unknown" }); - }), - ); - - it.effect("falls back to a warning when the ACP initialize probe fails", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const openHandsPath = yield* writeFakeOpenHandsCli({ acp: false }); - return yield* checkOpenHandsProviderStatus( - decodeOpenHandsSettings({ enabled: true, binaryPath: openHandsPath }), - ); - }), - ); - - expect(snapshot.status).toBe("warning"); - expect(snapshot.installed).toBe(true); - expect(snapshot.version).toBe("1.16.0"); - expect(snapshot.message).toContain("ACP initialize failed"); - }), - ); -}); diff --git a/apps/server/src/provider/Layers/OpenHandsProvider.ts b/apps/server/src/provider/Layers/OpenHandsProvider.ts deleted file mode 100644 index ea0d629dcab8..000000000000 --- a/apps/server/src/provider/Layers/OpenHandsProvider.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * OpenHandsProvider — status probing and snapshot building for the OpenHands CLI. - * - * OpenHands has no `models` listing command and its ACP session advertises no model - * state (see {@link ../acp/OpenHandsAcpSupport}), so unlike Grok this probe never - * discovers additional models. It also has no reliable local signal for auth state: - * a local install resolves its LLM credentials from `~/.openhands`, not from a CLI - * login step, so auth always reports `"unknown"` rather than guessing. - * - * @module OpenHandsProvider - */ -import { - type CustomModelSetting, - type ModelCapabilities, - type OpenHandsSettings, - type ServerProvider, -} from "@t3tools/contracts"; -import { causeErrorTag } from "@t3tools/shared/observability"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { - buildServerProvider, - isCommandMissingCause, - parseGenericCliVersion, - providerModelsFromSettings, - spawnAndCollect, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; -import { - enrichProviderSnapshotWithVersionAdvisory, - type ProviderMaintenanceCapabilities, -} from "../providerMaintenance.ts"; -import { - OPENHANDS_DEFAULT_MODEL_SLUG, - makeOpenHandsAcpRuntime, -} from "../acp/OpenHandsAcpSupport.ts"; - -const OPENHANDS_PRESENTATION = { - displayName: "OpenHands", - badgeLabel: "Early Access", - showInteractionModeToggle: false, -} as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -// OpenHands is a Python CLI: cold `--version` takes ~4s on this machine (uv -// tool install, heavy imports), so 4s was intermittently timing out. 15s is -// generous for cold caches while still failing fast on a broken install. -const VERSION_PROBE_TIMEOUT_MS = 15_000; -// `initialize` spawns a fresh `openhands acp` (another ~4s Python startup) -// before the handshake, so this must cover process boot plus the round trip. -const OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS = 15_000; - -const OPENHANDS_BUILT_IN_MODELS = [ - { - slug: OPENHANDS_DEFAULT_MODEL_SLUG, - name: "OpenHands Default", - isCustom: false, - capabilities: EMPTY_CAPABILITIES, - }, -]; - -export function buildInitialOpenHandsProviderSnapshot( - openHandsSettings: OpenHandsSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = openHandsModelsFromSettings(openHandsSettings.customModels); - - if (!openHandsSettings.enabled) { - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenHands is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking OpenHands CLI availability...", - }, - }); - }); -} - -function openHandsModelsFromSettings(customModels: ReadonlyArray | undefined) { - return providerModelsFromSettings( - OPENHANDS_BUILT_IN_MODELS, - customModels ?? [], - EMPTY_CAPABILITIES, - ); -} - -const runOpenHandsCliCommand = ( - openHandsSettings: OpenHandsSettings, - args: ReadonlyArray, - environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const command = openHandsSettings.binaryPath || "openhands"; - const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); - return yield* spawnAndCollect( - command, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: environment, - shell: spawnCommand.shell, - }), - ); - }); - -/** - * Confirms `openhands acp` can complete the ACP handshake. Only `initialize` is - * called — never `authenticate` or `session/new` — so this cannot open a browser - * login or boot the workspace's MCP servers. It exists to catch spawn-level - * breakage (wrong binary, broken entry point) that a bare `--version` probe would - * miss, since `openhands acp` and `openhands` share an entry point but not a code - * path once the subcommand dispatches. - */ -const probeOpenHandsAcpInitialize = ( - openHandsSettings: OpenHandsSettings, - environment: NodeJS.ProcessEnv, -) => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acp = yield* makeOpenHandsAcpRuntime({ - openHandsSettings, - environment, - childProcessSpawner, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - }); - yield* acp.initialize(); - }).pipe(Effect.scoped); - -export const checkOpenHandsProviderStatus = Effect.fn("checkOpenHandsProviderStatus")(function* ( - openHandsSettings: OpenHandsSettings, - environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return< - ServerProviderDraft, - never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto -> { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = openHandsModelsFromSettings(openHandsSettings.customModels); - - if (!openHandsSettings.enabled) { - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenHands is disabled in T3 Code settings.", - }, - }); - } - - const versionResult = yield* runOpenHandsCliCommand( - openHandsSettings, - ["--version"], - environment, - ).pipe(Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(versionResult)) { - const error = versionResult.failure; - yield* Effect.logWarning("OpenHands CLI health check failed.", { - errorTag: error._tag, - }); - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: openHandsSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "OpenHands CLI (`openhands`) is not installed or not on PATH." - : "Failed to execute OpenHands CLI health check.", - }, - }); - } - - if (Option.isNone(versionResult.success)) { - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: openHandsSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "OpenHands CLI is installed but timed out while running `openhands --version`.", - }, - }); - } - - const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); - if (versionOutput.code !== 0) { - yield* Effect.logWarning("OpenHands CLI version probe exited with a non-zero status.", { - exitCode: versionOutput.code, - stdoutLength: versionOutput.stdout.length, - stderrLength: versionOutput.stderr.length, - }); - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: openHandsSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "OpenHands CLI is installed but failed to run.", - }, - }); - } - - const acpExit = yield* probeOpenHandsAcpInitialize(openHandsSettings, environment).pipe( - Effect.timeoutOption(OPENHANDS_ACP_INITIALIZE_TIMEOUT_MS), - Effect.exit, - ); - const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); - if (acpFailed) { - yield* Effect.logWarning("OpenHands ACP initialize probe failed or timed out.", { - errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", - }); - } - - return buildServerProvider({ - presentation: OPENHANDS_PRESENTATION, - enabled: openHandsSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - // A failed ACP probe degrades the chat experience, it does not make the - // CLI itself unusable, so this is a warning rather than an error. - status: acpFailed ? "warning" : "ready", - auth: { status: "unknown" }, - ...(acpFailed - ? { - message: - "OpenHands CLI is installed but ACP initialize failed. Chat sessions may not start.", - } - : {}), - }, - }); -}); - -export const enrichOpenHandsSnapshot = (input: { - readonly snapshot: ServerProvider; - readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; - readonly enableProviderUpdateChecks?: boolean; - readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; - readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { - const { snapshot, publishSnapshot } = input; - - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { - enableProviderUpdateChecks: input.enableProviderUpdateChecks, - }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - Effect.catchCause((cause) => - Effect.logWarning("OpenHands version advisory enrichment failed", { - errorTag: causeErrorTag(cause), - }), - ), - Effect.asVoid, - ); -}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index abc9568c1d94..988c89e1e679 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2617,7 +2617,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", - "openhands", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Services/OpenHandsAdapter.ts b/apps/server/src/provider/Services/OpenHandsAdapter.ts deleted file mode 100644 index a80bc54d1cc2..000000000000 --- a/apps/server/src/provider/Services/OpenHandsAdapter.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * OpenHandsAdapter — shape type for the OpenHands provider adapter. - * - * Like {@link ../Drivers/CursorDriver}, the driver bundles one adapter per - * instance as a captured closure, so there is no `Context.Service` tag here — - * only the shape interface as a naming anchor for the driver bundle. - * - * @module OpenHandsAdapter - */ -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -/** - * OpenHandsAdapterShape — per-instance OpenHands adapter contract. Carries - * a branded driver kind as the nominal discriminant. - */ -export interface OpenHandsAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 9b77f5d33428..b5894192eed9 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -92,13 +92,7 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - /** - * Auth method to send before session setup. Omit for agents that authenticate - * out of band: `authenticate` is not optional in ACP, so an agent that only - * advertises an interactive method (OpenHands' cloud OAuth device flow) would - * otherwise either reject the call or start a login on every session. - */ - readonly authMethodId?: string; + readonly authMethodId: string; readonly mcpServers?: ReadonlyArray; /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; @@ -705,18 +699,15 @@ export const make = ( const startOnce = Effect.gen(function* () { const initializeResult = yield* sendInitialize; - const authMethodId = options.authMethodId; - if (authMethodId !== undefined) { - const authenticatePayload = { - methodId: authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); - } + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); let sessionId: string; let sessionSetupResult: diff --git a/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts b/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts deleted file mode 100644 index 4287c1930f94..000000000000 --- a/apps/server/src/provider/acp/OpenHandsAcpSupport.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - OPENHANDS_ALWAYS_APPROVE_MODE_ID, - OPENHANDS_ALWAYS_ASK_MODE_ID, - OPENHANDS_LLM_APPROVE_MODE_ID, - buildOpenHandsAcpSpawnInput, - openHandsAcpModeId, - openHandsAcpSpawnArgs, - resolveOpenHandsAcpBaseModelId, -} from "./OpenHandsAcpSupport.ts"; - -describe("resolveOpenHandsAcpBaseModelId", () => { - it("falls back to the built-in slug when no custom model id is set", () => { - expect(resolveOpenHandsAcpBaseModelId(undefined)).toBe("openhands-default"); - expect(resolveOpenHandsAcpBaseModelId(null)).toBe("openhands-default"); - expect(resolveOpenHandsAcpBaseModelId(" ")).toBe("openhands-default"); - }); - - it("trims and keeps an explicit model id", () => { - expect(resolveOpenHandsAcpBaseModelId(" openhands-custom-model ")).toBe( - "openhands-custom-model", - ); - }); -}); - -describe("openHandsAcpModeId", () => { - it("defaults to always-ask when no runtime mode is set", () => { - expect(openHandsAcpModeId(undefined)).toBe(OPENHANDS_ALWAYS_ASK_MODE_ID); - }); - - it("maps approval-required to always-ask", () => { - expect(openHandsAcpModeId("approval-required")).toBe(OPENHANDS_ALWAYS_ASK_MODE_ID); - }); - - it("maps auto-accept-edits and auto onto the LLM security analyzer", () => { - expect(openHandsAcpModeId("auto-accept-edits")).toBe(OPENHANDS_LLM_APPROVE_MODE_ID); - expect(openHandsAcpModeId("auto")).toBe(OPENHANDS_LLM_APPROVE_MODE_ID); - }); - - it("maps full-access to always-approve", () => { - expect(openHandsAcpModeId("full-access")).toBe(OPENHANDS_ALWAYS_APPROVE_MODE_ID); - }); -}); - -describe("openHandsAcpSpawnArgs", () => { - it("has no flag for the always-ask default", () => { - expect(openHandsAcpSpawnArgs()).toEqual(["acp"]); - expect(openHandsAcpSpawnArgs("approval-required")).toEqual(["acp"]); - }); - - it("passes --llm-approve for auto-accept-edits and auto", () => { - expect(openHandsAcpSpawnArgs("auto-accept-edits")).toEqual(["acp", "--llm-approve"]); - expect(openHandsAcpSpawnArgs("auto")).toEqual(["acp", "--llm-approve"]); - }); - - it("passes --always-approve for full-access", () => { - expect(openHandsAcpSpawnArgs("full-access")).toEqual(["acp", "--always-approve"]); - }); -}); - -describe("buildOpenHandsAcpSpawnInput", () => { - it("defaults to the `openhands` binary and suppresses the startup banner", () => { - const spawn = buildOpenHandsAcpSpawnInput(undefined, "/tmp/project"); - expect(spawn.command).toBe("openhands"); - expect(spawn.args).toEqual(["acp"]); - expect(spawn.cwd).toBe("/tmp/project"); - expect(spawn.env?.OPENHANDS_SUPPRESS_BANNER).toBe("1"); - }); - - it("honors a configured binary path override", () => { - const spawn = buildOpenHandsAcpSpawnInput( - { binaryPath: "/usr/local/bin/openhands" }, - "/tmp/project", - ); - expect(spawn.command).toBe("/usr/local/bin/openhands"); - }); - - it("merges the caller's environment and preserves the runtime mode flag", () => { - const spawn = buildOpenHandsAcpSpawnInput( - undefined, - "/tmp/project", - { FOO: "bar" }, - "full-access", - ); - expect(spawn.args).toEqual(["acp", "--always-approve"]); - expect(spawn.env?.FOO).toBe("bar"); - expect(spawn.env?.OPENHANDS_SUPPRESS_BANNER).toBe("1"); - }); -}); diff --git a/apps/server/src/provider/acp/OpenHandsAcpSupport.ts b/apps/server/src/provider/acp/OpenHandsAcpSupport.ts deleted file mode 100644 index ce16b74d4a7d..000000000000 --- a/apps/server/src/provider/acp/OpenHandsAcpSupport.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * OpenHandsAcpSupport — spawn and runtime wiring for the OpenHands CLI over ACP. - * - * The CLI ships an `openhands-acp` console script, but in 1.16.0 its generated - * entry point targets `openhands_cli.acp:main` while the wheel only contains - * `openhands_cli.acp_impl`, so the script raises `ModuleNotFoundError` on every - * invocation. The `openhands acp` subcommand runs the same server and is what we - * spawn; switch back to the dedicated binary only once it resolves upstream. - * - * @module OpenHandsAcpSupport - */ -import { - type OpenHandsSettings, - OPENHANDS_DEFAULT_MODEL, - type RuntimeMode, -} from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Scope from "effect/Scope"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import type * as EffectAcpErrors from "effect-acp/errors"; - -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; - -/** Keeps the startup banner off the wire. It goes to stderr, but stderr is logged. */ -const OPENHANDS_SUPPRESS_BANNER_ENV = "OPENHANDS_SUPPRESS_BANNER"; - -/** - * Confirmation modes the agent advertises in `session/new`. They double as the - * mode ids accepted by `session/set_mode`, so switching a live session uses the - * same vocabulary as the launch flags. - */ -export const OPENHANDS_ALWAYS_ASK_MODE_ID = "always-ask"; -export const OPENHANDS_LLM_APPROVE_MODE_ID = "llm-approve"; -export const OPENHANDS_ALWAYS_APPROVE_MODE_ID = "always-approve"; - -type OpenHandsAcpRuntimeSettings = Pick; - -export interface OpenHandsAcpRuntimeInput extends Omit< - AcpSessionRuntime.AcpSessionRuntimeOptions, - "authMethodId" | "spawn" -> { - readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly openHandsSettings: OpenHandsAcpRuntimeSettings | null | undefined; - readonly environment?: NodeJS.ProcessEnv; - readonly runtimeMode?: RuntimeMode; -} - -/** - * OpenHands has three confirmation modes and no edit-only tier, so - * `auto-accept-edits` and `auto` both land on the LLM security analyzer: it is - * the only setting that drops routine prompts while still stopping on the - * actions OpenHands rates high risk. - */ -export function openHandsAcpModeId(runtimeMode: RuntimeMode | undefined): string { - switch (runtimeMode) { - case "auto-accept-edits": - case "auto": - return OPENHANDS_LLM_APPROVE_MODE_ID; - case "full-access": - return OPENHANDS_ALWAYS_APPROVE_MODE_ID; - case "approval-required": - default: - return OPENHANDS_ALWAYS_ASK_MODE_ID; - } -} - -/** - * `acp` is an argparse subcommand, so its flags must follow it. Always-ask is the - * CLI default and has no flag of its own. - */ -export function openHandsAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { - switch (openHandsAcpModeId(runtimeMode)) { - case OPENHANDS_LLM_APPROVE_MODE_ID: - return ["acp", "--llm-approve"]; - case OPENHANDS_ALWAYS_APPROVE_MODE_ID: - return ["acp", "--always-approve"]; - default: - return ["acp"]; - } -} - -export function buildOpenHandsAcpSpawnInput( - openHandsSettings: OpenHandsAcpRuntimeSettings | null | undefined, - cwd: string, - environment?: NodeJS.ProcessEnv, - runtimeMode?: RuntimeMode, -): AcpSessionRuntime.AcpSpawnInput { - return { - command: openHandsSettings?.binaryPath || "openhands", - args: [...openHandsAcpSpawnArgs(runtimeMode)], - cwd, - env: { - ...environment, - [OPENHANDS_SUPPRESS_BANNER_ENV]: "1", - }, - }; -} - -/** - * Builds the session runtime. No `authMethodId` is sent: OpenHands advertises only - * its cloud OAuth device flow, and a local install is already authenticated through - * `~/.openhands`, so calling `authenticate` would start a browser login per session. - */ -export const makeOpenHandsAcpRuntime = ( - input: OpenHandsAcpRuntimeInput, -): Effect.Effect< - AcpSessionRuntime.AcpSessionRuntime["Service"], - EffectAcpErrors.AcpError, - Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const acpContext = yield* Layer.build( - AcpSessionRuntime.layer({ - ...input, - spawn: buildOpenHandsAcpSpawnInput( - input.openHandsSettings, - input.cwd, - input.environment, - input.runtimeMode, - ), - }).pipe( - Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), - ), - ), - ); - return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( - Effect.provide(acpContext), - ); - }); - -/** - * T3's built-in OpenHands slug. OpenHands resolves its LLM from `~/.openhands` and - * its ACP session carries no model state, so the slug only ever means "whatever the - * CLI is configured with" and is never sent over the wire. - */ -export const OPENHANDS_DEFAULT_MODEL_SLUG = OPENHANDS_DEFAULT_MODEL; - -export function resolveOpenHandsAcpBaseModelId(model: string | null | undefined): string { - return model?.trim() || OPENHANDS_DEFAULT_MODEL_SLUG; -} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 8a2f68953b7c..60e3402eed42 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,7 +25,6 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; -import { OpenHandsDriver, type OpenHandsDriverEnv } from "./Drivers/OpenHandsDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -40,7 +39,6 @@ export type BuiltInDriversEnv = | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - | OpenHandsDriverEnv | AntigravityDriverEnv; /** @@ -54,6 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): string { - return writeFakeCli({ - directory: NodePath.join(dir, "bin"), - name: "openhands", - env, - source: execScriptSource({ - scriptPath: mockAgentPath, - expectedArgs: ["acp"], - }), - }); -} - -function withFakeAcpOpenHands( - env: Record, - effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, -) { - return Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-openhands-text-acp-"), - ); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - }), - ); - const binaryPath = makeAcpOpenHandsWrapper(tempDir, env); - const config = decodeOpenHandsSettings({ binaryPath }); - const textGeneration = yield* makeOpenHandsTextGeneration(config); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -function readJsonRpcRequests( - filePath: string, -): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { - return NodeFS.readFileSync(filePath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: Record }); -} - -// OpenHands has no model catalog, so `session/set_model` is never sent; the -// requested model id only routes commands to this driver. -const modelSelection = createModelSelection( - ProviderInstanceId.make("openhands"), - "openhands-default", -); - -it.layer(OpenHandsTextGenerationTestLayer)("OpenHandsTextGeneration", (it) => { - it.effect("uses ACP with disabled tool capabilities and always-ask mode", () => { - const requestLogDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-openhands-text-log-"), - ); - const requestLogPath = NodePath.join(requestLogDir, "requests.ndjson"); - - return withFakeAcpOpenHands( - { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Add OpenHands provider", - body: "Wire up the ACP runtime and headless text generation path.", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/openhands", - stagedSummary: "M apps/server/src/provider/Drivers/OpenHandsDriver.ts", - stagedPatch: "diff --git a/.../OpenHandsDriver.ts b/.../OpenHandsDriver.ts", - modelSelection, - }); - - expect(generated.subject).toBe("Add OpenHands provider"); - expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); - - const requests = readJsonRpcRequests(requestLogPath); - expect( - requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, - ).toMatchObject({ - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }); - expect(requests.some((request) => request.method === "session/set_model")).toBe(false); - }), - ); - }); - - it.effect("extracts the JSON object when OpenHands wraps it in conversational text", () => - withFakeAcpOpenHands( - { - T3_ACP_PROMPT_RESPONSE_TEXT: - "Sure! Here's a thread title:\n\n" + - JSON.stringify({ title: "Investigate failing CI" }) + - "\n\nLet me know if you need anything else.", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "the lint job is red", - modelSelection, - }); - expect(generated.title).toBe("Investigate failing CI"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is empty", () => - withFakeAcpOpenHands( - { - T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection, - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/empty/i); - }), - ), - ); - - it.effect("decodes a structured PR title + body", () => - withFakeAcpOpenHands( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: "feat(openhands): wire up ACP text generation", - body: "## Summary\n- Spawn `openhands acp` for headless text generation.\n- Extract JSON output from conversational wrapping.", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feat/openhands-provider", - commitSummary: "feat: add openhands provider", - diffSummary: "M apps/server/src/provider/Drivers/OpenHandsDriver.ts", - diffPatch: "diff --git a/.../OpenHandsDriver.ts b/.../OpenHandsDriver.ts", - modelSelection, - }); - - expect(generated.title).toBe("feat(openhands): wire up ACP text generation"); - expect(generated.body).toContain("Spawn `openhands acp`"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is unparseable JSON", () => - withFakeAcpOpenHands( - { - T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection, - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/invalid structured output/i); - }), - ), - ); - - it.effect("decodes a branch name suggestion", () => - withFakeAcpOpenHands( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "feature/wire-up-openhands" }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "wire up openhands", - modelSelection, - }); - expect(generated.branch).toBe("feature/wire-up-openhands"); - }), - ), - ); -}); diff --git a/apps/server/src/textGeneration/OpenHandsTextGeneration.ts b/apps/server/src/textGeneration/OpenHandsTextGeneration.ts deleted file mode 100644 index 612bf6854329..000000000000 --- a/apps/server/src/textGeneration/OpenHandsTextGeneration.ts +++ /dev/null @@ -1,253 +0,0 @@ -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { type OpenHandsSettings } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { TextGenerationError } from "@t3tools/contracts"; - -import * as TextGeneration from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "./TextGenerationUtils.ts"; -import { - OPENHANDS_ALWAYS_ASK_MODE_ID, - makeOpenHandsAcpRuntime, -} from "../provider/acp/OpenHandsAcpSupport.ts"; - -const OPENHANDS_TIMEOUT_MS = 180_000; - -const isTextGenerationError = Schema.is(TextGenerationError); - -/** - * Build an OpenHands text-generation closure bound to a specific - * `OpenHandsSettings` payload. See `makeCodexAdapter` for the overall - * per-instance rationale. - * - * The helper runs the agent in `always-ask` and never registers a permission - * handler, so any tool the model reaches for stalls instead of touching the - * repository — these prompts only ever need text back. - */ -export const makeOpenHandsTextGeneration = Effect.fn("makeOpenHandsTextGeneration")(function* ( - openHandsSettings: OpenHandsSettings, - environment?: NodeJS.ProcessEnv, -) { - const crypto = yield* Crypto.Crypto; - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resolvedEnvironment = environment ?? process.env; - - const runOpenHandsJson = ({ - operation, - cwd, - prompt, - outputSchemaJson, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - }): Effect.Effect => - Effect.gen(function* () { - const outputRef = yield* Ref.make(""); - const runtime = yield* makeOpenHandsAcpRuntime({ - openHandsSettings, - environment: resolvedEnvironment, - childProcessSpawner: commandSpawner, - cwd, - clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); - - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; - } - const content = update.content; - if (content.type !== "text") { - return Effect.void; - } - return Ref.update(outputRef, (current) => current + content.text); - }); - - const promptResult = yield* Effect.gen(function* () { - yield* runtime.start(); - yield* Effect.ignore(runtime.setMode(OPENHANDS_ALWAYS_ASK_MODE_ID)); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(OPENHANDS_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: "OpenHands request timed out.", - }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "OpenHands ACP request failed.", - cause, - }), - ), - ); - - const rawResult = (yield* Ref.get(outputRef)).trim(); - if (!rawResult) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "OpenHands ACP request was cancelled." - : "OpenHands returned empty output.", - }); - } - - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(rawResult)).pipe( - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "OpenHands returned invalid structured output.", - cause, - }), - ), - }), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "OpenHands ACP text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("OpenHandsTextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - policy: input.policy, - }); - - const generated = yield* runOpenHandsJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("OpenHandsTextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - policy: input.policy, - changeRequestTemplate: input.changeRequestTemplate, - }); - - const generated = yield* runOpenHandsJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("OpenHandsTextGeneration.generateBranchName")(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runOpenHandsJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("OpenHandsTextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - previousTitle: input.previousTitle, - attachments: input.attachments, - }); - - const generated = yield* runOpenHandsJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies TextGeneration.ThreadTitleGenerationResult; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; -}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 80464a411113..199d0ba834d0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,5 +1,4 @@ import React, { type SVGProps, useId } from "react"; -import { Bot } from "lucide-react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -656,9 +655,6 @@ export const AntigravityIcon: Icon = (props) => ( ); -// ponytail: generic placeholder (no real OpenHands brand asset on hand) — swap for the actual mark when available. -export const OpenHandsIcon: Icon = (props) => ; - export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 60e97b05ab6b..db0e5ca222f3 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -7,7 +7,6 @@ import { Icon, OpenAI, OpenCodeIcon, - OpenHandsIcon, } from "../Icons"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -17,7 +16,6 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, - [ProviderDriverKind.make("openhands")]: OpenHandsIcon, }; export type ModelEsque = { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bf0cc9814dab..4bf4da3919ba 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -5,7 +5,6 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, - OpenHandsSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; @@ -17,7 +16,6 @@ import { type Icon, OpenAI, OpenCodeIcon, - OpenHandsIcon, } from "../Icons"; type ProviderSettingsSchema = { @@ -84,13 +82,6 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: AntigravityIcon, settingsSchema: AntigravitySettings, }, - { - value: ProviderDriverKind.make("openhands"), - label: "OpenHands", - icon: OpenHandsIcon, - badgeLabel: "Early Access", - settingsSchema: OpenHandsSettings, - }, ]; const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 56f12a7cbd0d..bce1a766bc9b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,7 +148,6 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -const OPENHANDS_DRIVER_KIND = ProviderDriverKind.make("openhands"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -164,12 +163,6 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; -/** - * Keep the model the OpenHands CLI is configured with. Never send this ID to ACP: - * OpenHands resolves its LLM from `~/.openhands`, and its ACP session advertises no - * model state, so T3 has nothing to select against. - */ -export const OPENHANDS_DEFAULT_MODEL = "openhands-default"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { @@ -179,7 +172,6 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial { }), ); + it.effect("repairs a standards-compliant JSON-RPC error into a typed failure", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const firstMessage = yield* Deferred.make(); + yield* transport.clientProtocol + .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + // A plain `{code, message}` error, as a standards-compliant (non-Effect) ACP + // agent sends it: no `_tag: "Cause"` marker, so effect/rpc's codec boxes it as + // a `Die` unless `repairJsonRpcErrorExit` rewrites it back into a `Fail`. + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + id: 5, + error: { code: -32601, message: "Method not found" }, + })}\n`, + ), + ); + + const message = yield* Deferred.await(firstMessage); + assert.equal(message._tag, "Exit"); + const exit = (message as { readonly exit: { readonly _tag: string; readonly cause: any } }) + .exit; + assert.equal(exit._tag, "Failure"); + assert.deepEqual(exit.cause, [ + { _tag: "Fail", error: { code: -32601, message: "Method not found" } }, + ]); + }), + ); + it.effect("preserves numeric ids for inbound extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio();