diff --git a/.gitignore b/.gitignore index 07793efe9b5..0435d2814ca 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build/ release/ release-mock/ .t3 +temp/ .idea/ apps/web/.playwright apps/web/playwright-report diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c4595..45247823af0 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import { useColorScheme } from "react-native"; -import { Path, Svg } from "react-native-svg"; +import { Path, Rect, Svg } from "react-native-svg"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -58,6 +58,48 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "piAgent") { + return ( + + + + + + ); + } + + if (props.provider === "hermes") { + return ( + + + + + + + ); + } + + if (props.provider === "openclaw") { + return ( + + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 951b74f7d51..7171b423b37 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -36,6 +36,9 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "piAgent") return "Pi"; + if (provider.driver === "hermes") return "Hermes"; + if (provider.driver === "openclaw") return "OpenClaw"; return provider.instanceId; } diff --git a/apps/server/scripts/openclaw-mock-gateway.ts b/apps/server/scripts/openclaw-mock-gateway.ts new file mode 100644 index 00000000000..ed73d5a8f09 --- /dev/null +++ b/apps/server/scripts/openclaw-mock-gateway.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// +// openclaw-mock-gateway.ts — a scripted stand-in for the `openclaw gateway` +// binary used by the OpenClaw runtime tests. The real OpenClaw gateway is not +// installed in CI or on contributor machines, so the spawn-path tests launch +// this script through a tiny shell wrapper and drive it through the same +// WebSocket protocol the runtime speaks to a real gateway. +// +// It accepts the real CLI shape (`gateway --port --allow-unconfigured`) +// and honors `OPENCLAW_GATEWAY_TOKEN`, delegating the protocol handling to +// {@link ../src/provider/testUtils/openclawMockGateway}. +// +// Behavior is selected with T3_OPENCLAW_* environment variables; unset flags +// produce the happy-path gateway. + +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +async function main(): Promise { + const args = process.argv.slice(2); + const portIndex = args.indexOf("--port"); + let port = 18_789; + if (portIndex >= 0 && args[portIndex + 1]) { + port = Number(args[portIndex + 1]); + } + const token = process.env.OPENCLAW_GATEWAY_TOKEN; + + // Resolve the testUtils module relative to this script. When spawned via + // the tsx/vite transform the import graph resolves through the server src + // tree; fall back to a direct file URL when the package mapping is absent. + let startMock: typeof import("../src/provider/testUtils/openclawMockGateway.ts").startMockOpenClawGateway; + try { + ({ startMockOpenClawGateway: startMock } = + await import("../src/provider/testUtils/openclawMockGateway.ts")); + } catch { + ({ startMockOpenClawGateway: startMock } = await import( + NodeURL.pathToFileURL( + NodePath.join(__dirname, "..", "src", "provider", "testUtils", "openclawMockGateway.ts"), + ).href + )); + } + + const handle = await startMock({ + port, + ...(token !== undefined ? { token } : {}), + serverVersion: "2026.8.1-mock", + emitThinking: process.env.T3_OPENCLAW_EMIT_THINKING === "1", + emitToolEvents: process.env.T3_OPENCLAW_EMIT_TOOL_EVENTS === "1", + emitApproval: process.env.T3_OPENCLAW_EMIT_APPROVAL === "1", + }); + // The gateway listens on the requested port; the mock bound to an ephemeral + // port, so log the real one on a line the runtime can parse if needed. + console.log(`mock openclaw gateway listening on ${handle.port}`); + const onSignal = () => { + void handle.close().then(() => process.exit(0)); + }; + process.on("SIGTERM", onSignal); + process.on("SIGINT", onSignal); + process.on("SIGHUP", onSignal); +} + +main().catch((error) => { + console.error(`mock openclaw gateway error: ${String(error)}`); + process.exit(1); +}); diff --git a/apps/server/scripts/pi-mock-agent.ts b/apps/server/scripts/pi-mock-agent.ts new file mode 100644 index 00000000000..e92565c6dd8 --- /dev/null +++ b/apps/server/scripts/pi-mock-agent.ts @@ -0,0 +1,383 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// +// pi-mock-agent.ts — a scripted stand-in for the `pi --mode rpc` binary used +// by the PiAgent adapter tests. The real pi binary is not installed in CI or +// on contributor machines, so the adapter tests spawn this script through a +// tiny shell wrapper and drive it through the same JSONL-over-stdio protocol +// the adapter speaks to real pi. +// +// Behavior is selected with T3_PI_* environment variables; see each flag +// below. Unset flags produce a minimal happy-path agent that answers a +// prompt with one text delta and settles. + +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; + +const sessionId = process.env.T3_PI_SESSION_ID ?? "mock-pi-session-1"; +const sessionName = process.env.T3_PI_SESSION_NAME ?? "mock-pi-session-name"; +const requestLogPath = process.env.T3_PI_REQUEST_LOG_PATH; +const exitLogPath = process.env.T3_PI_EXIT_LOG_PATH; +const hangPromptForever = process.env.T3_PI_HANG_PROMPT_FOREVER === "1"; +const failPrompt = process.env.T3_PI_FAIL_PROMPT === "1"; +const emitToolCalls = process.env.T3_PI_EMIT_TOOL_CALLS === "1"; +const emitThinking = process.env.T3_PI_EMIT_THINKING === "1"; +const emitConfirm = process.env.T3_PI_EMIT_CONFIRM === "1"; +const emitSelect = process.env.T3_PI_EMIT_SELECT === "1"; +const emitInput = process.env.T3_PI_EMIT_INPUT === "1"; +const emitNotify = process.env.T3_PI_EMIT_NOTIFY === "1"; +const emitExtensionError = process.env.T3_PI_EMIT_EXTENSION_ERROR === "1"; +const emitWillRetry = process.env.T3_PI_EMIT_AGENT_END_WITH_RETRY === "1"; +const exitOnStart = process.env.T3_PI_EXIT_ON_START === "1"; +const promptResponseText = process.env.T3_PI_PROMPT_RESPONSE_TEXT ?? "hello from mock pi"; +const requestedModel = process.env.T3_PI_MODEL_ID ?? "claude-sonnet-4-6"; +const settleDelayMs = Number(process.env.T3_PI_DELAY_SETTLE_MS ?? "0"); + +let currentModel = requestedModel; +let currentThinkingLevel = "medium"; +let isStreaming = false; + +const pendingUi: Array<{ id: string; method: string; promise: Promise }> = []; + +function writeRecord(record: unknown): void { + process.stdout.write(`${JSON.stringify(record)}\n`); +} + +function logExit(reason: string): void { + if (!exitLogPath) return; + NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); +} + +function logRequest(request: Record): void { + if (!requestLogPath) return; + NodeFS.appendFileSync(requestLogPath, `${JSON.stringify(request)}\n`, "utf8"); +} + +function response(command: string, id: string | undefined, data?: unknown, error?: string): void { + writeRecord({ + type: "response", + command, + success: error === undefined, + ...(data !== undefined ? { data } : {}), + ...(error !== undefined ? { error } : {}), + ...(id !== undefined ? { id } : {}), + }); +} + +function emitAgentStart(): void { + writeRecord({ type: "agent_start" }); +} + +function emitAgentEnd(): void { + writeRecord({ + type: "agent_end", + messages: [{ id: "mock-msg-1", role: "assistant", content: promptResponseText }], + willRetry: emitWillRetry, + }); +} + +function emitAgentSettled(): void { + isStreaming = false; + writeRecord({ type: "agent_settled" }); +} + +function emitMessageTurn(): void { + writeRecord({ type: "message_start", message: { id: "mock-msg-1", role: "assistant" } }); + if (emitThinking) { + writeRecord({ + type: "message_update", + assistantMessageEvent: { type: "thinking_start" }, + }); + writeRecord({ + type: "message_update", + assistantMessageEvent: { type: "thinking_delta", delta: "mock thinking" }, + }); + writeRecord({ + type: "message_update", + assistantMessageEvent: { type: "thinking_end" }, + }); + } + writeRecord({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: promptResponseText }, + }); + writeRecord({ + type: "message_update", + assistantMessageEvent: { type: "text_end", contentIndex: 0 }, + }); + writeRecord({ + type: "message_end", + message: { + id: "mock-msg-1", + role: "assistant", + content: promptResponseText, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + }); +} + +function emitMockToolCalls(): void { + writeRecord({ + type: "tool_execution_start", + toolCallId: "mock-call-1", + toolName: "bash", + args: { command: "ls" }, + }); + writeRecord({ + type: "tool_execution_update", + toolCallId: "mock-call-1", + partialResult: "file.txt", + }); + writeRecord({ + type: "tool_execution_end", + toolCallId: "mock-call-1", + toolName: "bash", + result: "file.txt", + isError: false, + }); +} + +function emitExtensionUi(): void { + if (emitConfirm) { + writeRecord({ + type: "extension_ui_request", + id: "mock-ui-confirm", + method: "confirm", + title: "Approve command", + message: "Run `ls`?", + }); + } + if (emitSelect) { + writeRecord({ + type: "extension_ui_request", + id: "mock-ui-select", + method: "select", + title: "Choose scope", + message: "Which scope?", + options: [ + { value: "workspace", label: "Workspace" }, + { value: "project", label: "Project" }, + ], + }); + } + if (emitInput) { + writeRecord({ + type: "extension_ui_request", + id: "mock-ui-input", + method: "input", + title: "Free text", + message: "Type something", + }); + } + if (emitNotify) { + writeRecord({ + type: "extension_ui_request", + id: "mock-ui-notify", + method: "notify", + title: "Heads up", + message: "A notification", + }); + } +} + +function handlePrompt(request: Record): void { + const id = typeof request.id === "string" ? request.id : undefined; + // A steer prompt arrives while a run is in flight; pi folds it into the + // ongoing run, so ack it without emitting a second set of turn events. + if (isStreaming) { + response("prompt", id, { ok: true, steered: true }); + return; + } + isStreaming = true; + emitAgentStart(); + emitExtensionUi(); + if (emitExtensionError) { + writeRecord({ type: "extension_error", message: "mock extension failure" }); + response("prompt", id, undefined, "extension_error"); + isStreaming = false; + return; + } + if (failPrompt) { + response("prompt", id, undefined, "mock prompt failure"); + isStreaming = false; + return; + } + if (hangPromptForever) { + // Ack the prompt but never emit agent events; the test aborts, and the + // abort handler below settles the run. + response("prompt", id, { ok: true }); + return; + } + emitMockToolCalls(); + writeRecord({ type: "turn_start" }); + emitMessageTurn(); + writeRecord({ type: "turn_end", message: { id: "mock-msg-1" }, toolResults: [] }); + // Ack the prompt immediately; the agent keeps working until it settles + // (settleDelayMs is only about agent_end/agent_settled). + response("prompt", id, { ok: true }); + setTimeout(() => { + emitAgentEnd(); + emitAgentSettled(); + }, settleDelayMs); +} + +function handleAbort(): void { + if (isStreaming) { + emitAgentEnd(); + emitAgentSettled(); + isStreaming = false; + } + response("abort", undefined, { aborted: true }); +} + +async function run(): Promise { + if (exitOnStart) { + logExit("exited-on-start"); + process.exit(0); + } + + // Log the signal that terminated us so the adapter's stop-session test + // can assert the child was killed rather than left running. + for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + process.on(signal, () => { + logExit(signal); + process.exit(0); + }); + } + + const rl = NodeReadline.createInterface({ input: process.stdin }); + rl.on("line", (line) => { + void handleLine(line); + }); + rl.on("close", () => { + logExit("stdin-closed"); + process.exit(0); + }); + + async function handleLine(line: string): Promise { + let request: Record; + try { + request = JSON.parse(line) as Record; + } catch { + return; + } + logRequest(request); + const type = request.type; + const requestId = typeof request.id === "string" ? request.id : undefined; + + if (type === "get_state") { + response("get_state", requestId, { + model: currentModel, + thinkingLevel: currentThinkingLevel, + isStreaming, + sessionFile: `sessions/${sessionId}.jsonl`, + sessionId, + sessionName, + }); + return; + } + if (type === "get_available_models") { + response("get_available_models", requestId, { + models: [ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic", + provider: "anthropic", + baseUrl: null, + reasoning: true, + input: 0, + contextWindow: 200000, + maxTokens: 64000, + cost: null, + }, + { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + api: "anthropic", + provider: "anthropic", + baseUrl: null, + reasoning: false, + input: 0, + contextWindow: 200000, + maxTokens: 32000, + cost: null, + }, + { + id: "gpt-5", + name: "GPT-5", + api: "openai", + provider: "openai", + baseUrl: null, + reasoning: true, + input: 0, + contextWindow: 400000, + maxTokens: 128000, + cost: null, + }, + ], + }); + return; + } + if (type === "prompt") { + void handlePrompt(request); + return; + } + if (type === "abort") { + handleAbort(); + return; + } + if (type === "set_model") { + currentModel = typeof request.modelId === "string" ? request.modelId : currentModel; + response("set_model", requestId, { model: currentModel }); + return; + } + if (type === "set_thinking_level") { + currentThinkingLevel = + typeof request.level === "string" ? request.level : currentThinkingLevel; + response("set_thinking_level", requestId, { level: currentThinkingLevel }); + return; + } + if (type === "set_session_name") { + response("set_session_name", requestId, {}); + return; + } + if (type === "get_session_stats") { + response("get_session_stats", requestId, { + tokens: { input: 100, output: 50 }, + cost: 0.01, + contextUsage: { usedTokens: 150, maxTokens: 200000 }, + }); + return; + } + if (type === "get_messages") { + response("get_messages", requestId, { + messages: [ + { id: "mock-user-1", role: "user", content: "hello" }, + { id: "mock-msg-1", role: "assistant", content: promptResponseText }, + ], + }); + return; + } + if ( + type === "switch_session" || + type === "new_session" || + type === "get_entries" || + type === "get_commands" + ) { + response(String(type), requestId, {}); + return; + } + if (type === "extension_ui_response") { + // Fire-and-forget; pi does not ack extension UI responses. + return; + } + + response(String(type), requestId, {}); + } +} + +run().catch((error) => { + logExit(`mock-pi-error: ${String(error)}`); + process.exit(1); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 2aa057ee0ca..36fbd86b5c5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -59,6 +59,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsMakeDirectory]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsDeleteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..4353f1a7aa0 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,155 @@ +import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { 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 { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { + buildInitialHermesProviderSnapshot, + checkHermesProviderStatus, +} from "../Layers/HermesProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +// Hermes installs via its own installer script (`curl ... | sh`), so updates +// stay manual; the advisory still shows the installed version. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "hermes-agent", + }), +); + +export type HermesDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: (): HermesSettings => decodeHermesSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies HermesSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeHermesAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkHermesProviderStatus(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>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialHermesProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Hermes snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/OpenClawDriver.ts b/apps/server/src/provider/Drivers/OpenClawDriver.ts new file mode 100644 index 00000000000..4433c93b7e3 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenClawDriver.ts @@ -0,0 +1,194 @@ +/** + * OpenClawDriver — `ProviderDriver` for the OpenClaw gateway. + * + * Mirrors the OpenCode driver: a plain value whose `create()` bundles + * `snapshot` / `adapter` / `textGeneration` closures over the per-instance + * `OpenClawSettings`. + * + * OpenClaw is server-backed — one gateway per instance hosts every session — + * so `create()` builds a shared {@link OpenClawGatewayHolder} (spawn-or-connect, + * lazy) that both the adapter and text generation use. The holder's process + * lifetime is bound to `gatewayScope`, which the registry's scope closes when + * the instance is torn down. + * + * @module provider/Drivers/OpenClawDriver + */ +import { OpenClawSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +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 { makeOpenClawTextGeneration } from "../../textGeneration/OpenClawTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenClawAdapter, makeOpenClawGatewayHolder } from "../Layers/OpenClawAdapter.ts"; +import { + checkOpenClawProviderStatus, + makePendingOpenClawProvider, +} from "../Layers/OpenClawProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenClawRuntime } from "../openclawRuntime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeOpenClawSettings = Schema.decodeSync(OpenClawSettings); + +const DRIVER_KIND = ProviderDriverKind.make("openclaw"); +// OpenClaw ships as an npm global; updates stay manual (no reliable update +// command), but the advisory shows the installed version. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "openclaw", + }), +); + +export type OpenClawDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | OpenClawRuntime + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenClawDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenClaw", + supportsMultipleInstances: true, + }, + configSchema: OpenClawSettings, + defaultConfig: (): OpenClawSettings => decodeOpenClawSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenClawSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + // One gateway per instance, shared by the adapter and text generation. + // The scope owns the spawned gateway process; closing it on teardown + // kills the child and interrupts the event pump. + const gatewayScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(gatewayScope, Exit.void).pipe(Effect.ignore)); + const gateway = yield* makeOpenClawGatewayHolder(gatewayScope); + + // Spawned gateways get an isolated state dir under the T3 instance state + // so they never touch a user's `~/.openclaw`. + const stateDir = path.join(serverConfig.stateDir, "providers", "openclaw", instanceId); + + const adapter = yield* makeOpenClawAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + stateDir, + gateway, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeOpenClawTextGeneration(effectiveConfig, { + gateway, + environment: processEnv, + }); + + const openClawRuntime = yield* OpenClawRuntime; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const checkProvider = checkOpenClawProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenClawRuntime, openClawRuntime), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>( + { + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingOpenClawProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenClaw snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/PiAgentDriver.ts b/apps/server/src/provider/Drivers/PiAgentDriver.ts new file mode 100644 index 00000000000..b6f0dbaa643 --- /dev/null +++ b/apps/server/src/provider/Drivers/PiAgentDriver.ts @@ -0,0 +1,155 @@ +import { PiAgentSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { 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 { makePiAgentTextGeneration } from "../../textGeneration/PiAgentTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makePiAgentAdapter } from "../Layers/PiAgentAdapter.ts"; +import { + buildInitialPiAgentProviderSnapshot, + checkPiAgentProviderStatus, +} from "../Layers/PiAgentProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodePiAgentSettings = Schema.decodeSync(PiAgentSettings); + +const DRIVER_KIND = ProviderDriverKind.make("piAgent"); +// Pi ships as an npm global; updates stay manual (no reliable update +// command), but the advisory shows the installed version. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "@earendil-works/pi-coding-agent", + }), +); + +export type PiAgentDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const PiAgentDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Pi", + supportsMultipleInstances: true, + }, + configSchema: PiAgentSettings, + defaultConfig: (): PiAgentSettings => decodePiAgentSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiAgentSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makePiAgentAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makePiAgentTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkPiAgentProviderStatus(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>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialPiAgentProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Pi snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caadd..34c943ad7ab 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -53,6 +53,7 @@ import { } from "../acp/AcpCoreRuntimeEvents.ts"; import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { applyAcpReasoningConfig } from "../acp/AcpReasoningConfig.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -745,6 +746,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + yield* applyAcpReasoningConfig({ + runtime: acp, + selections: grokModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); const now = yield* nowIso; const session: ProviderSession = { @@ -949,6 +956,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const reasoningEffort = yield* applyAcpReasoningConfig({ + runtime: ctx.acp, + selections: turnModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1034,7 +1052,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte provider: PROVIDER, threadId: input.threadId, turnId, - payload: displayModel ? { model: displayModel } : {}, + payload: { + ...(displayModel ? { model: displayModel } : {}), + ...(reasoningEffort ? { effort: reasoningEffort } : {}), + }, }); } diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..8fca976e357 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,7 +6,11 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + buildGrokDiscoveredModelsFromSessionModelState, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); @@ -108,3 +112,54 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); }); + +describe("buildGrokDiscoveredModelsFromSessionModelState", () => { + const grokModelState = { + currentModelId: "grok-build", + availableModels: [ + { modelId: "grok-build", name: "Grok Build" }, + { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, + ], + }; + + it("attaches a reasoning descriptor when the ACP server declares an effort option", () => { + const models = buildGrokDiscoveredModelsFromSessionModelState(grokModelState, [ + { + id: "reasoning", + name: "Reasoning Effort", + category: "model_config", + type: "select", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ]); + expect(models.map((model) => model.slug)).toEqual(["grok-build", "grok-mock-alt"]); + for (const model of models) { + const reasoning = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoning", + ); + expect(reasoning?.type).toBe("select"); + } + }); + + it("stays descriptor-less when no effort option is advertised", () => { + const models = buildGrokDiscoveredModelsFromSessionModelState(grokModelState, []); + expect(models.map((model) => model.slug)).toEqual(["grok-build", "grok-mock-alt"]); + for (const model of models) { + expect(model.capabilities?.optionDescriptors).toEqual([]); + } + }); + + it("returns no models when the session model state is empty", () => { + expect(buildGrokDiscoveredModelsFromSessionModelState(undefined, undefined)).toEqual([]); + expect( + buildGrokDiscoveredModelsFromSessionModelState( + { currentModelId: "grok-build", availableModels: [] }, + undefined, + ), + ).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae..62afc65493d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -30,6 +30,7 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { acpReasoningCapabilities } from "../acp/AcpReasoningConfig.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -99,12 +100,18 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } -function buildGrokDiscoveredModelsFromSessionModelState( +export function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, + configOptions: ReadonlyArray | null | undefined = undefined, ): ReadonlyArray { if (!modelState || modelState.availableModels.length === 0) { return []; } + // ACP config options are session-scoped, so the same reasoning capability + // applies to every discovered model. The fallback catalog (used when + // discovery fails) stays descriptor-less because the CLI's options are + // unknown in that path. + const capabilities = acpReasoningCapabilities(configOptions); const seen = new Set(); return modelState.availableModels .map((model): ServerProviderModel | undefined => { @@ -117,7 +124,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities, }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -137,7 +144,11 @@ const discoverGrokModelsViaAcp = ( clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); - return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + const configOptions = yield* acp.getConfigOptions; + return buildGrokDiscoveredModelsFromSessionModelState( + started.sessionSetupResult.models, + configOptions, + ); }).pipe(Effect.scoped); const runGrokVersionCommand = ( diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts new file mode 100644 index 00000000000..b251876ba97 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -0,0 +1,532 @@ +// @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 Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { + ApprovalRequestId, + HermesSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { setMcpProviderSession, clearMcpProviderSession } from "../../mcp/McpProviderSession.ts"; +import { makeHermesAdapter } from "./HermesAdapter.ts"; +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockHermesWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-hermes.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function makeSpawnLoggingHermesWrapper(spawnLogPath: string) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-acp-spawn-")); + const wrapperPath = NodePath.join(dir, "fake-hermes-spawn.sh"); + const script = `#!/bin/sh +printf '%s\\n' "$*" > ${JSON.stringify(spawnLogPath)} +printf 'HERMES_HOME=%s\\n' "$HERMES_HOME" >> ${JSON.stringify(spawnLogPath)} +printf 'HERMES_ACP_SKIP_CONFIGURED_MCP=%s\\n' "$HERMES_ACP_SKIP_CONFIGURED_MCP" >> ${JSON.stringify(spawnLogPath)} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +function waitForFileContent( + filePath: string, + attempts = 40, + expectedContent?: string, +): Effect.Effect { + const readAttempt = (remainingAttempts: number): Effect.Effect => + Effect.gen(function* () { + if (remainingAttempts <= 0) { + return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); + } + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if ( + raw.trim().length > 0 && + (expectedContent === undefined || raw.includes(expectedContent)) + ) { + return raw; + } + yield* Effect.sleep("25 millis"); + return yield* readAttempt(remainingAttempts - 1); + }); + return readAttempt(attempts); +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const hermesAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-hermes-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeHermesAdapter(decodeHermesSettings({ binaryPath }), options).pipe(Effect.orDie); + +const HERMES_THREAD = ThreadId.make("hermes-mock-thread"); + +it.layer(hermesAdapterTestLayer)("HermesAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockHermesWrapper()); + 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: HERMES_THREAD, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-mock-alt" }, + }); + + assert.equal(session.provider, "hermes"); + assert.equal(session.model, "grok-mock-alt"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId: HERMES_THREAD, + input: "hello hermes", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.configured", + "session.state.changed", + "thread.started", + "turn.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"); + } + const completed = runtimeEvents.find((e) => e.type === "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "completed"); + assert.equal(completed.payload.stopReason, "end_turn"); + } + + yield* adapter.stopSession(HERMES_THREAD); + }), + ); + + it.effect("passes profile, home path and launch args to the hermes acp spawn", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-acp-args-")), + ); + const spawnLogPath = NodePath.join(tempDir, "spawn.log"); + const wrapperPath = yield* Effect.promise(() => makeSpawnLoggingHermesWrapper(spawnLogPath)); + const adapter = yield* makeHermesAdapter( + decodeHermesSettings({ + binaryPath: wrapperPath, + homePath: "/custom/hermes-home", + profile: "work", + launchArgs: "--foo bar", + }), + ).pipe(Effect.orDie); + + const session = yield* adapter.startSession({ + threadId: ThreadId.make("hermes-spawn-args"), + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const spawnLog = yield* waitForFileContent(spawnLogPath); + assert.include(spawnLog, "--profile work acp --foo bar"); + assert.include(spawnLog, "HERMES_HOME=/custom/hermes-home"); + + yield* adapter.stopSession(ThreadId.make("hermes-spawn-args")); + assert.equal(session.provider, "hermes"); + }), + ); + + it.effect("sets HERMES_ACP_SKIP_CONFIGURED_MCP when an MCP session is bound", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-mcp-skip"); + setMcpProviderSession({ + environmentId: "env-1" as never, + threadId, + providerSessionId: "mcp-session-1", + providerInstanceId: ProviderInstanceId.make("hermes"), + endpoint: "https://mcp.example.test/sse", + authorizationHeader: "Bearer mock-token", + }); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-acp-mcp-")), + ); + const spawnLogPath = NodePath.join(tempDir, "spawn.log"); + const wrapperPath = yield* Effect.promise(() => makeSpawnLoggingHermesWrapper(spawnLogPath)); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const spawnLog = yield* waitForFileContent(spawnLogPath); + assert.include(spawnLog, "HERMES_ACP_SKIP_CONFIGURED_MCP=1"); + + yield* adapter.stopSession(threadId); + yield* Effect.sync(() => clearMcpProviderSession(threadId)); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockHermesWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId: ThreadId.make("hermes-provider-mismatch"), + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-build" }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("rejects sendTurn with empty input and no attachments", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-empty-turn"); + const wrapperPath = yield* Effect.promise(() => makeMockHermesWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: " ", + attachments: [], + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps approval decisions to Hermes permission option ids", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-approval-mapping"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-acp-approval-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + // Use Hermes' real stable option ids (allow_once/allow_always/deny). + const wrapperPath = yield* Effect.promise(() => + makeMockHermesWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "allow_once", + T3_ACP_ALLOW_ALWAYS_OPTION_ID: "allow_always", + T3_ACP_REJECT_ONCE_OPTION_ID: "deny", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const decisions = yield* Queue.unbounded(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Queue.take(decisions).pipe( + Effect.flatMap((decision) => + adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + decision, + ), + ), + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "approval-required", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-build" }, + }); + + yield* Queue.offer(decisions, "accept"); + yield* adapter.sendTurn({ threadId, input: "approve once", attachments: [] }); + yield* Queue.offer(decisions, "acceptForSession"); + yield* adapter.sendTurn({ threadId, input: "approve for session", attachments: [] }); + yield* Queue.offer(decisions, "decline"); + yield* adapter.sendTurn({ threadId, input: "deny", attachments: [] }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const resolvedOptionIds = requests + .map((entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + typeof entry.result.outcome.optionId === "string" + ? entry.result.outcome.optionId + : undefined, + ) + .filter((id): id is string => id !== undefined); + assert.deepEqual(resolvedOptionIds, ["allow_once", "allow_always", "deny"]); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("restores a session through the resume cursor", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-resume"); + const wrapperPath = yield* Effect.promise(() => + makeMockHermesWrapper({ T3_ACP_EMIT_LOAD_REPLAY: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-build" }, + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, + }); + + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + // Replayed load notifications must not leak into the runtime stream. + assert.isFalse( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.delta === "replayed assistant text", + ), + ); + + yield* adapter.sendTurn({ + threadId, + input: "after resume", + attachments: [], + }); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects rollbackThread because Hermes ACP has no rollback", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-rollback"); + const wrapperPath = yield* Effect.promise(() => makeMockHermesWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + assert.equal(error._tag, "ProviderAdapterValidationError"); + assert.include(error.message, "rollback"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails respondToUserInput cleanly when no user-input surface exists", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-user-input"); + const wrapperPath = yield* Effect.promise(() => makeMockHermesWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const error = yield* Effect.flip( + adapter.respondToUserInput(threadId, ApprovalRequestId.make("nope"), {}), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "hermes-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockHermesWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* waitForFileContent(exitLogPath); + assert.include(exitLog, "SIGTERM"); + }), + ); + + it.effect("cancels an in-flight prompt when interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("hermes-cancel"); + const wrapperPath = yield* Effect.promise(() => + makeMockHermesWrapper({ T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.started" ? Deferred.succeed(turnStarted, undefined) : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("hermes"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("hermes"), model: "grok-build" }, + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang forever", attachments: [] }) + .pipe(Effect.forkChild); + + yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(cancelledEvents, 1); + assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 00000000000..78c3c60b760 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,1382 @@ +/** + * HermesAdapter — per-instance Hermes adapter. + * + * Maps the Hermes ACP stdio server (`hermes acp`) onto the canonical + * `ProviderRuntimeEvent` stream via the shared ACP session runtime + * ({@link ../acp/AcpSessionRuntime}). + * + * Design decisions (verified against Hermes' ACP source in + * acp_adapter/{server,permissions,auth}.py and its public docs): + * + * - **Transport.** `hermes acp` is an ACP stdio server. It advertises a + * terminal setup auth method (`hermes-setup`) but reuses Hermes' own + * runtime credentials, so T3 authenticates with `hermes-setup` and ignores + * the returned auth info. + * - **Model switching.** Hermes implements `session/set_model`, so + * `capabilities.sessionModelSwitch` is `"in-session"`. Hermes encodes model + * ids as `provider:model`; `session/set_model` resolves T3's + * `provider/model` slugs through `parse_model_input`. + * - **Approvals.** Hermes presents stable permission option ids: + * `allow_once`, `allow_session` (session-scoped "Allow for session"), + * `allow_always`, `deny`, `deny_always`. T3 decisions map accept → + * `allow_once`, acceptForSession → `allow_session` (falling back to + * `allow_always`), decline → `deny`. `full-access` runtime mode + * auto-approves with the session-scoped option. + * - **Steering.** A prompt sent while Hermes is already running is folded + * into the active turn (Hermes redirects or queues it), so sendTurn reuses + * the active turn id — same contract as the Grok adapter. + * - **rollback.** Hermes ACP has no turn rollback, so `rollbackThread` fails + * with `ProviderAdapterValidationError`; checkpoint revert still restores + * the workspace. + * - **user input.** Hermes has no ask-user extension surface, so + * `respondToUserInput` fails clean. + * + * @module provider/Layers/HermesAdapter + */ +import { + ApprovalRequestId, + EventId, + type HermesSettings, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { applyAcpReasoningConfig } from "../acp/AcpReasoningConfig.ts"; +import { + applyHermesAcpModelSelection, + currentHermesModelIdFromSessionSetup, + makeHermesAcpRuntime, +} from "../acp/HermesAcpSupport.ts"; +import { type HermesAdapterShape } from "../Services/HermesAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface HermesAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface HermesSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: HermesSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: HermesSessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? ctx.activeTurnId : undefined; +}; + +function parseHermesResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== HERMES_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +/** + * Select the Hermes permission option id for a T3 approval decision. + * + * Option ids are stable in Hermes' ACP adapter (acp_adapter/permissions.py): + * `allow_once`, `allow_session`, `allow_always`, `deny`, `deny_always`. + * Fall back to standard ACP kinds, then to the shared outcome string + * (`acpPermissionOutcome`), for servers that present different ids — Hermes + * maps unknown option ids to deny, so the fallback can only downgrade. + */ +function selectHermesPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const preferredIds = + decision === "acceptForSession" + ? (["allow_session", "allow_always"] as const) + : decision === "accept" + ? (["allow_once"] as const) + : (["deny", "deny_always"] as const); + for (const optionId of preferredIds) { + if (request.options.some((entry) => entry.optionId === optionId)) { + return optionId; + } + } + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const byKind = request.options.find((entry) => entry.kind === kind); + return byKind?.optionId.trim() || acpPermissionOutcome(decision); +} + +function selectAutoApprovedHermesPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectHermesPermissionOptionId(request, "acceptForSession") ?? + selectHermesPermissionOptionId(request, "accept") + ); +} + +export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* ( + hermesSettings: HermesSettings, + options?: HermesAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("hermes"); + 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 Hermes runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Hermes ACP callback.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx || liveCtx.stopped) { + return; + } + const settlementBelongsToLiveContext = + liveCtx.acpSessionId === expectedAcpSessionId && + (liveCtx.activeTurnId === turnId || liveCtx.session.activeTurnId === turnId); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Hermes notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: HermesSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method: "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: HermesSessionContext) => + 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 startSession: HermesAdapterShape["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 hermesModelSelection = + 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), + ); + + const resumeSessionId = parseHermesResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeHermesAcpRuntime({ + hermesSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + skipConfiguredMcp: mcpSession !== undefined, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedHermesPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" + ? undefined + : selectHermesPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = hermesModelSelection?.model?.trim() || undefined; + const boundModelId = yield* applyHermesAcpModelSelection({ + runtime: acp, + currentModelId: currentHermesModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + yield* applyAcpReasoningConfig({ + runtime: acp, + selections: hermesModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(requestedStartModelId + ? { model: requestedStartModelId } + : boundModelId + ? { model: boundModelId } + : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: HERMES_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: HermesSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Hermes runtime notification.", { cause }), + ), + Effect.forkChild, + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: resumeSessionId !== undefined }, + }); + yield* offerRuntimeEvent({ + type: "session.configured", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { + config: { + binaryPath: hermesSettings.binaryPath, + homePath: hermesSettings.homePath, + profile: hermesSettings.profile, + launchArgs: hermesSettings.launchArgs, + ...(requestedStartModelId ? { model: requestedStartModelId } : {}), + }, + }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Hermes 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: HermesAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: Hermes folds + // the new prompt into the ongoing work (redirect or queue), so the + // active turn id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model?.trim() || undefined; + const currentModelId = yield* applyHermesAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + const reasoningEffort = yield* applyAcpReasoningConfig({ + runtime: ctx.acp, + selections: turnModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = requestedTurnModelId ?? currentModelId ?? undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Hermes prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + ...(displayModel ? { model: displayModel } : {}), + ...(reasoningEffort ? { effort: reasoningEffort } : {}), + }, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Hermes prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => Ref.set(promptResultRef, promptResult)), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: "Hermes session changed before the turn completed.", + settleAllPrompts: true, + }); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Hermes session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult !== undefined) { + const liveCtx = sessions.get(input.threadId); + if (liveCtx && !liveCtx.stopped && liveCtx.acpSessionId === prepared.acpSessionId) { + appendPromptResultToTurn( + liveCtx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + } + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + completedStopReason: promptResult.stopReason, + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Hermes prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: HermesAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: HermesAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: HermesAdapterShape["respondToUserInput"] = () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: + "Hermes ACP exposes no user-input extension surface; there are no user-input requests to answer.", + }), + ); + + const readThread: HermesAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: HermesAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Hermes ACP has no turn rollback; checkpoint revert restores the workspace.", + }); + }); + + const stopSession: HermesAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: HermesAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: HermesAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: HermesAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies HermesAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/HermesProvider.test.ts b/apps/server/src/provider/Layers/HermesProvider.test.ts new file mode 100644 index 00000000000..1bbefb53a8b --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.test.ts @@ -0,0 +1,286 @@ +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HermesSettings } from "@t3tools/contracts"; + +import { + buildInitialHermesProviderSnapshot, + checkHermesProviderStatus, + hermesDiscoveredModelsFromSessionModelState, + hermesModelSlugFromAcpModelId, +} from "./HermesProvider.ts"; + +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +/** Writes a fake `hermes` CLI that answers --version itself and defers `acp` + * to the shared ACP mock agent. */ +const writeMockHermesWrapper = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-hermes-acp-models-" }); + const wrapperPath = path.join(dir, "hermes"); + const script = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf "hermes 0.8.0 (2026.4.8) [af4abd2f]\\n" + exit 0 +fi +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + yield* fs.writeFileString(wrapperPath, script); + yield* fs.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +describe("buildInitialHermesProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialHermesProviderSnapshot( + decodeHermesSettings({ 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 a pending snapshot with the static model catalog by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialHermesProviderSnapshot(decodeHermesSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Hermes"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + "openai/gpt-5", + ]); + }), + ); + + it.effect("appends custom models on top of the static catalog", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialHermesProviderSnapshot( + decodeHermesSettings({ customModels: ["openrouter:z-ai/glm-5.1"] }), + ); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + "openai/gpt-5", + "openrouter:z-ai/glm-5.1", + ]); + const custom = snapshot.models.find((model) => model.slug === "openrouter:z-ai/glm-5.1"); + expect(custom?.isCustom).toBe(true); + }), + ); +}); + +describe("hermesModelSlugFromAcpModelId", () => { + it("converts provider:model ids to provider/model slugs", () => { + expect(hermesModelSlugFromAcpModelId("openrouter:deepseek/deepseek-v4-flash-0731")).toBe( + "openrouter/deepseek/deepseek-v4-flash-0731", + ); + expect(hermesModelSlugFromAcpModelId("anthropic:claude-sonnet-4.6")).toBe( + "anthropic/claude-sonnet-4.6", + ); + }); + + it("passes bare model ids through", () => { + expect(hermesModelSlugFromAcpModelId("grok-build")).toBe("grok-build"); + }); + + it("rejects empty and malformed ids", () => { + expect(hermesModelSlugFromAcpModelId(" ")).toBeUndefined(); + expect(hermesModelSlugFromAcpModelId(":no-provider")).toBeUndefined(); + expect(hermesModelSlugFromAcpModelId("no-model:")).toBeUndefined(); + }); +}); + +describe("hermesDiscoveredModelsFromSessionModelState", () => { + it("maps available models to slugs and dedupes", () => { + const models = hermesDiscoveredModelsFromSessionModelState({ + currentModelId: "openrouter:deepseek/deepseek-v4-flash-0731", + availableModels: [ + { + modelId: "openrouter:deepseek/deepseek-v4-flash-0731", + name: "OpenRouter · deepseek/deepseek-v4-flash-0731", + }, + { + modelId: "openrouter:deepseek/deepseek-v4-flash-0731", + name: "duplicate", + }, + { modelId: "anthropic:claude-sonnet-4.6", name: "Anthropic · claude-sonnet-4.6" }, + ], + }); + expect(models.map((model) => model.slug)).toEqual([ + "openrouter/deepseek/deepseek-v4-flash-0731", + "anthropic/claude-sonnet-4.6", + ]); + expect(models[0]?.name).toBe("OpenRouter · deepseek/deepseek-v4-flash-0731"); + expect(models.every((model) => !model.isCustom)).toBe(true); + }); + + it("returns an empty list for missing model state", () => { + expect(hermesDiscoveredModelsFromSessionModelState(undefined)).toEqual([]); + expect( + hermesDiscoveredModelsFromSessionModelState({ + currentModelId: "grok-build", + availableModels: [], + }), + ).toEqual([]); + }); + + it("attaches a reasoning descriptor when the ACP server declares an effort option", () => { + const models = hermesDiscoveredModelsFromSessionModelState( + { + currentModelId: "openrouter:deepseek/deepseek-v4-flash-0731", + availableModels: [ + { + modelId: "openrouter:deepseek/deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash", + }, + ], + }, + [ + { + id: "effort", + name: "Reasoning", + category: "model_option", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + ); + expect(models.map((model) => model.slug)).toEqual([ + "openrouter/deepseek/deepseek-v4-flash-0731", + ]); + const reasoning = models[0]?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoning", + ); + expect(reasoning?.type).toBe("select"); + }); + + it("stays descriptor-less when no effort option is advertised", () => { + const models = hermesDiscoveredModelsFromSessionModelState( + { + currentModelId: "grok-build", + availableModels: [{ modelId: "grok-build", name: "Grok Build" }], + }, + [], + ); + expect(models[0]?.capabilities?.optionDescriptors).toEqual([]); + }); +}); + +it.layer(NodeServices.layer)("checkHermesProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkHermesProviderStatus( + decodeHermesSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/hermes-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 error when ACP model discovery is unavailable", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-hermes-success-" }); + const hermesPath = path.join(dir, "hermes"); + yield* fs.writeFileString( + hermesPath, + ["#!/bin/sh", 'printf "hermes 0.8.0 (2026.4.8) [af4abd2f]\\n"', "exit 0", ""].join( + "\n", + ), + ); + yield* fs.chmod(hermesPath, 0o755); + + return yield* checkHermesProviderStatus( + decodeHermesSettings({ enabled: true, binaryPath: hermesPath }), + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("0.8.0"); + expect(snapshot.message).toContain("ACP startup failed"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + "openai/gpt-5", + ]); + }), + ); + + it.effect("uses discovered models from the ACP session model state", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeMockHermesWrapper; + return yield* checkHermesProviderStatus( + decodeHermesSettings({ enabled: true, binaryPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.8.0"); + // The shared ACP mock agent advertises these via session/new. + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build", "grok-mock-alt"]); + }), + ); + + 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 path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-hermes-version-" }); + const hermesPath = path.join(dir, "hermes"); + yield* fs.writeFileString( + hermesPath, + ["#!/bin/sh", 'printf "%s\\n" "broken hermes install" >&2', "exit 2", ""].join("\n"), + ); + yield* fs.chmod(hermesPath, 0o755); + + return yield* checkHermesProviderStatus( + decodeHermesSettings({ enabled: true, binaryPath: hermesPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Hermes CLI is installed but failed to run."); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesProvider.ts b/apps/server/src/provider/Layers/HermesProvider.ts new file mode 100644 index 00000000000..261897b4fa4 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -0,0 +1,368 @@ +import { + type HermesSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { makeHermesAcpRuntime } from "../acp/HermesAcpSupport.ts"; +import { acpReasoningCapabilities } from "../acp/AcpReasoningConfig.ts"; + +const HERMES_PRESENTATION = { + displayName: "Hermes", + // Hermes ACP implements session/set_model, so a new thread is not + // required for a model change. + requiresNewThreadForModelChange: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +/** + * Static catalog used when the ACP model discovery cannot be completed. + * Custom models from settings are always appended on top. + */ +const HERMES_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "anthropic/claude-haiku-4.5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "openai/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function hermesModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = HERMES_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildInitialHermesProviderSnapshot( + hermesSettings: HermesSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = hermesModelsFromSettings(hermesSettings.customModels); + + if (!hermesSettings.enabled) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hermes CLI availability...", + }, + }); + }); +} + +const runHermesVersionCommand = ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = hermesSettings.binaryPath || "hermes"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +/** + * Hermes encodes ACP model ids as `provider:model` (e.g. + * `openrouter:deepseek/deepseek-v4-flash-0731`); T3 slugs are + * `provider/model`. Only the first `:` is structural — the model id itself + * may contain `/`. Bare ids (no provider prefix) pass through unchanged. + */ +export function hermesModelSlugFromAcpModelId(modelId: string): string | undefined { + const trimmed = modelId.trim(); + if (!trimmed) { + return undefined; + } + const separator = trimmed.indexOf(":"); + if (separator <= 0 || separator === trimmed.length - 1) { + return separator < 0 ? trimmed : undefined; + } + return `${trimmed.slice(0, separator)}/${trimmed.slice(separator + 1)}`; +} + +/** + * Map the ACP `session/new` model state to provider models. Hermes builds + * `availableModels` from its shared inventory (`hermes model`, TUI, and + * dashboard use the same substrate), so this lists every authenticated + * provider's models, not just the current one. + */ +export function hermesDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, + configOptions: ReadonlyArray | null | undefined = undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + // ACP config options are session-scoped, so the same reasoning capability + // applies to every discovered model. The fallback static catalog (used when + // discovery fails) stays descriptor-less because the CLI's options are + // unknown in that path. + const capabilities = acpReasoningCapabilities(configOptions); + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = hermesModelSlugFromAcpModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +/** + * Discover models over ACP: spawn `hermes acp` (skipping Hermes' globally + * configured MCP servers — the probe never runs tools) and read the model + * state advertised by `session/new`. The runtime owns the process; closing + * the scope kills it. + */ +const discoverHermesModelsViaAcp = ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeHermesAcpRuntime({ + hermesSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + skipConfiguredMcp: true, + }); + const started = yield* acp.start(); + const configOptions = yield* acp.getConfigOptions; + return hermesDiscoveredModelsFromSessionModelState( + started.sessionSetupResult.models, + configOptions, + ); + }).pipe(Effect.scoped); + +export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = hermesModelsFromSettings(hermesSettings.customModels); + + if (!hermesSettings.enabled) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runHermesVersionCommand(hermesSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Hermes CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Hermes CLI (`hermes`) is not installed or not on PATH." + : "Failed to execute Hermes CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but timed out while running `hermes --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Hermes CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but failed to run.", + }, + }); + } + + // Sessions run over ACP, so an ACP startup failure here means sessions + // would fail too; degrade the status the same way the Grok probe does. + const discoveryExit = yield* discoverHermesModelsViaAcp(hermesSettings, environment).pipe( + Effect.timeoutOption(HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Hermes ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Hermes ACP model discovery timed out after ${HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Hermes CLI is installed but ACP startup timed out after ${HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? hermesModelsFromSettings(hermesSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.test.ts b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts new file mode 100644 index 00000000000..aa96e4e2983 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts @@ -0,0 +1,700 @@ +// @effect-diagnostics nodeBuiltinImport:off +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 Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { + ApprovalRequestId, + OpenClawSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSessionStartInput, + ThreadId, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { + type OpenClawGatewayConnection, + OpenClawRuntime, + OpenClawRuntimeError, + OpenClawRuntimeLive, +} from "../openclawRuntime.ts"; +import { startMockOpenClawGateway } from "../testUtils/openclawMockGateway.ts"; +import { + isOpenClawSessionNotFound, + makeOpenClawAdapter, + type OpenClawGatewayHolder, + parseOpenClawResume, +} from "./OpenClawAdapter.ts"; + +const decodeOpenClawSettings = Schema.decodeSync(OpenClawSettings); + +const openClawAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-openclaw-adapter-test-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(OpenClawRuntimeLive.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer)))), +); + +/** + * A holder that connects every acquire to the given mock gateway URL. The + * connection is cached so the adapter's event pump and its RPC calls share + * one socket, exactly like the driver-owned holder does. The scope is a + * throwaway used only to satisfy the runtime's connect signature; external + * gateway connections do not register process finalizers on it. + */ +const makeTestGatewayHolder = ( + url: string, +): Effect.Effect => + Effect.gen(function* () { + const runtime = yield* OpenClawRuntime; + const gatewayScope = yield* Scope.make(); + let cached: OpenClawGatewayConnection | undefined; + return { + acquire: (input) => + Effect.gen(function* () { + if (cached) { + return cached; + } + const connection = yield* runtime + .connectToOpenClawGateway({ + binaryPath: input.binaryPath, + gatewayUrl: url, + ...(input.gatewayToken?.trim() ? { gatewayToken: input.gatewayToken } : {}), + }) + .pipe(Effect.provideService(Scope.Scope, gatewayScope)); + cached = connection; + return connection; + }), + }; + }); + +const makeTestAdapter = (url: string) => + Effect.gen(function* () { + const gateway = yield* makeTestGatewayHolder(url); + return yield* makeOpenClawAdapter(decodeOpenClawSettings({}), { gateway }).pipe(Effect.orDie); + }); + +const startSessionInput = ( + threadId: ThreadId, + resumeCursor?: unknown, +): ProviderSessionStartInput => ({ + threadId, + provider: ProviderDriverKind.make("openclaw"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("openclaw"), + model: "anthropic/claude-sonnet-4-6", + }, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), +}); + +interface EventTracker { + readonly events: ProviderRuntimeEvent[]; + readonly fiber: Fiber.Fiber; + /** Resolves once every type in `doneTypes` has been seen for the thread. */ + readonly done: Deferred.Deferred; +} + +/** + * One subscriber on the adapter event stream. Tests must never open a second + * consumer on the same queue — items would be split between them. + */ +const trackEvents = ( + stream: Stream.Stream, + threadId: ThreadId, + doneTypes: ReadonlyArray, + onEvent?: (event: ProviderRuntimeEvent) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const events: ProviderRuntimeEvent[] = []; + const seen = new Set(); + const done = yield* Deferred.make(); + const fiber = yield* Stream.runForEach(stream, (event) => + Effect.sync(() => { + events.push(event); + if (String(event.threadId) === String(threadId) && doneTypes.includes(event.type)) { + seen.add(event.type); + } + }).pipe( + Effect.andThen(() => + String(event.threadId) === String(threadId) && onEvent !== undefined + ? onEvent(event) + : Effect.void, + ), + Effect.andThen(() => + doneTypes.every((type) => seen.has(type)) + ? Deferred.succeed(done, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + return { events, fiber, done }; + }); + +const findThreadEvent = ( + events: ProviderRuntimeEvent[], + threadId: ThreadId, + type: T, +) => + events.find( + (event): event is Extract => + event.type === type && String(event.threadId) === String(threadId), + ); + +it("parses a versioned resume cursor and rejects stale or malformed values", () => { + assert.deepEqual(parseOpenClawResume({ schemaVersion: 1, sessionId: "sess-1" }), { + sessionId: "sess-1", + }); + assert.deepEqual(parseOpenClawResume({ schemaVersion: 1, sessionId: " sess-1 " }), { + sessionId: "sess-1", + }); + assert.isUndefined(parseOpenClawResume({ schemaVersion: 2, sessionId: "sess-1" })); + assert.isUndefined(parseOpenClawResume({ schemaVersion: 1, sessionId: " " })); + assert.isUndefined(parseOpenClawResume({ schemaVersion: 1 })); + assert.isUndefined(parseOpenClawResume(undefined)); + assert.isUndefined(parseOpenClawResume(null)); + assert.isUndefined(parseOpenClawResume("garbage")); + assert.isUndefined(parseOpenClawResume([1, 2])); +}); + +it("recognizes NOT_FOUND-family codes only when deciding a session is gone", () => { + assert.isTrue(isOpenClawSessionNotFound({ code: "NOT_FOUND" })); + assert.isTrue(isOpenClawSessionNotFound({ code: "SESSION_NOT_FOUND" })); + assert.isTrue(isOpenClawSessionNotFound({ code: "NOT_FOUND", message: "missing" })); + assert.isTrue(isOpenClawSessionNotFound({ details: { code: "NOT_FOUND" } })); + // The runtime wraps gateway failures in OpenClawRuntimeError and keeps the + // structured code in `cause`; that must still count as a confirmed miss. + assert.isTrue( + isOpenClawSessionNotFound( + new OpenClawRuntimeError({ + operation: "sessions.describe", + detail: "NOT_FOUND: mock session not found", + cause: { code: "NOT_FOUND", message: "mock session not found" }, + }), + ), + ); + assert.isFalse(isOpenClawSessionNotFound({ code: "INTERNAL" })); + assert.isFalse(isOpenClawSessionNotFound({ code: "UNAUTHORIZED" })); + assert.isFalse(isOpenClawSessionNotFound({ message: "session not found" })); + assert.isFalse(isOpenClawSessionNotFound("garbage")); + assert.isFalse(isOpenClawSessionNotFound(undefined)); + assert.isFalse(isOpenClawSessionNotFound(null)); +}); + +it.live("runs a full session and turn against the mock gateway", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-full-session"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["turn.completed"]); + + const session = yield* adapter.startSession(startSessionInput(threadId)); + assert.equal(session.provider, "openclaw"); + assert.equal(session.status, "ready"); + const sessionKey = (session.resumeCursor as { sessionId: string }).sessionId; + assert.equal(sessionKey.startsWith("t3-"), true); + + const turn = yield* adapter.sendTurn({ threadId, input: "hello", attachments: [] }); + assert.equal(String(turn.turnId).startsWith("openclaw-turn-"), true); + + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const orderedTypes = tracker.events + .map((event) => event.type) + .filter((type) => + [ + "session.started", + "session.configured", + "session.state.changed", + "thread.started", + "turn.started", + "content.delta", + "turn.completed", + ].includes(type), + ); + assert.deepEqual(orderedTypes, [ + "session.started", + "session.configured", + "session.state.changed", + "thread.started", + "turn.started", + "content.delta", + "turn.completed", + ]); + + const started = findThreadEvent(tracker.events, threadId, "session.started"); + assert.equal(started?.payload.resume, false); + + const configured = findThreadEvent(tracker.events, threadId, "session.configured"); + assert.equal(configured?.payload.config.binaryPath, "openclaw"); + + const stateChanged = findThreadEvent(tracker.events, threadId, "session.state.changed"); + assert.equal(stateChanged?.payload.state, "ready"); + + const threadStarted = findThreadEvent(tracker.events, threadId, "thread.started"); + assert.equal(threadStarted?.payload.providerThreadId, sessionKey); + + const delta = findThreadEvent(tracker.events, threadId, "content.delta"); + assert.equal(delta?.payload.streamKind, "assistant_text"); + assert.equal(delta?.payload.delta, "hello from mock openclaw (hello)"); + + const completed = findThreadEvent(tracker.events, threadId, "turn.completed"); + assert.equal(completed?.payload.state, "completed"); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.find((entry) => entry.threadId === threadId)?.status, "ready"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("maps thinking and tool lifecycles to reasoning and item events", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => + startMockOpenClawGateway({ emitThinking: true, emitToolEvents: true }), + ); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-tool-events"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["turn.completed"]); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "use tools", attachments: [] }); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const reasoning = findThreadEvent(tracker.events, threadId, "content.delta"); + assert.equal(reasoning?.payload.streamKind, "reasoning_text"); + assert.equal(reasoning?.payload.delta, "mock thinking"); + + const itemStarted = findThreadEvent(tracker.events, threadId, "item.started"); + assert.equal(itemStarted?.itemId, "mock-call-1"); + assert.equal(itemStarted?.payload.itemType, "command_execution"); + assert.equal(itemStarted?.payload.title, "Ran command"); + assert.equal(itemStarted?.payload.status, "inProgress"); + + const itemCompleted = findThreadEvent(tracker.events, threadId, "item.completed"); + assert.equal(itemCompleted?.payload.itemType, "command_execution"); + assert.equal(itemCompleted?.payload.status, "completed"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("surfaces gateway approvals as request.opened and auto-resolved requests", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ emitApproval: true })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-approval-auto"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["turn.completed"]); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "approve", attachments: [] }); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const opened = findThreadEvent(tracker.events, threadId, "request.opened"); + assert.equal(opened?.requestId, "mock-approval-1"); + assert.equal(opened?.payload.requestType, "command_execution_approval"); + assert.equal(opened?.payload.detail, "rm -rf /tmp/x"); + assert.equal( + opened?.payload.args !== undefined && (opened.payload.args as Record).kind, + "exec", + ); + + const resolved = findThreadEvent(tracker.events, threadId, "request.resolved"); + assert.equal(resolved?.requestId, "mock-approval-1"); + assert.equal(resolved?.payload.decision, "approved"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("resolves a pending approval with allow when the user accepts", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => + startMockOpenClawGateway({ emitApproval: true, resolveApproval: false }), + ); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-approval-accept"); + const requestOpened = yield* Deferred.make(); + const tracker = yield* trackEvents( + adapter.streamEvents, + threadId, + ["turn.completed"], + (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, undefined).pipe(Effect.ignore) + : Effect.void, + ); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "approve", attachments: [] }); + yield* Deferred.await(requestOpened); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("mock-approval-1"), "accept"); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const resolveFrame = mock.requests.find( + (request) => request.method === "exec.approval.resolve", + ); + assert.equal( + resolveFrame?.params !== undefined && (resolveFrame.params as Record).id, + "mock-approval-1", + ); + assert.equal( + resolveFrame?.params !== undefined && + (resolveFrame.params as Record).decision, + "allow", + ); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("resolves a pending approval with deny when the user declines", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => + startMockOpenClawGateway({ emitApproval: true, resolveApproval: false }), + ); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-approval-decline"); + const requestOpened = yield* Deferred.make(); + const tracker = yield* trackEvents( + adapter.streamEvents, + threadId, + ["turn.completed"], + (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, undefined).pipe(Effect.ignore) + : Effect.void, + ); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "deny", attachments: [] }); + yield* Deferred.await(requestOpened); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("mock-approval-1"), "decline"); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const resolveFrame = mock.requests.find( + (request) => request.method === "exec.approval.resolve", + ); + assert.equal( + resolveFrame?.params !== undefined && + (resolveFrame.params as Record).decision, + "deny", + ); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("aborts a hanging run and completes the turn as interrupted", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ agentDelayMs: 250 })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-interrupt"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["turn.completed"]); + + const session = yield* adapter.startSession(startSessionInput(threadId)); + const sessionKey = (session.resumeCursor as { sessionId: string }).sessionId; + const turn = yield* adapter.sendTurn({ threadId, input: "hang", attachments: [] }); + yield* adapter.interruptTurn(threadId, turn.turnId); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const aborts = mock.requests.filter((request) => request.method === "chat.abort"); + assert.equal(aborts.length, 1); + assert.equal( + aborts[0]?.params !== undefined && (aborts[0].params as Record).sessionKey, + sessionKey, + ); + assert.equal( + aborts[0]?.params !== undefined && (aborts[0].params as Record).runId, + "mock-run-1", + ); + + const completed = findThreadEvent(tracker.events, threadId, "turn.completed"); + assert.equal(completed?.payload.state, "interrupted"); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.find((entry) => entry.threadId === threadId)?.status, "ready"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("fails the active turn and exits the session when the gateway closes", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ hangAgent: true })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-gateway-close"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, [ + "turn.completed", + "session.exited", + ]); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "run", attachments: [] }); + yield* Effect.promise(() => mock.close()); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const completed = findThreadEvent(tracker.events, threadId, "turn.completed"); + assert.equal(completed?.payload.state, "failed"); + assert.isString(completed?.payload.errorMessage); + + const exited = findThreadEvent(tracker.events, threadId, "session.exited"); + assert.equal(exited?.payload.exitKind, "error"); + assert.equal(exited?.payload.recoverable, false); + assert.isString(exited?.payload.reason); + + const hasSession = yield* adapter.hasSession(threadId); + assert.equal(hasSession, false); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("resumes an existing gateway session from a versioned cursor", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-resume-known"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["session.started"]); + + const session = yield* adapter.startSession( + startSessionInput(threadId, { schemaVersion: 1, sessionId: "known-session" }), + ); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "known-session", + }); + assert.equal( + mock.requests.some( + (request) => + request.method === "sessions.describe" && + request.params !== undefined && + (request.params as Record).key === "known-session", + ), + true, + ); + assert.equal( + mock.requests.some((request) => request.method === "sessions.create"), + false, + ); + const started = findThreadEvent(tracker.events, threadId, "session.started"); + assert.equal(started?.payload.resume, true); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("starts a fresh session when the resumed gateway session is gone", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => + startMockOpenClawGateway({ sessionNotFoundOnDescribe: true }), + ); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-resume-unknown"); + + const session = yield* adapter.startSession( + startSessionInput(threadId, { schemaVersion: 1, sessionId: "gone-session" }), + ); + + const creates = mock.requests.filter((request) => request.method === "sessions.create"); + assert.equal(creates.length, 1); + const createdKey = + creates[0]?.params !== undefined + ? (creates[0].params as Record).key + : undefined; + assert.equal(typeof createdKey, "string"); + assert.equal((createdKey as string).startsWith("t3-"), true); + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: createdKey, + }); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("reads the thread back from chat.history grouped into turns", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-read-thread"); + + yield* adapter.startSession(startSessionInput(threadId)); + const snapshot = yield* adapter.readThread(threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(String(snapshot.turns[0]?.id), "mock-msg-1"); + assert.equal(snapshot.turns[0]?.items.length, 2); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("emits a graceful session.exited when a session stops", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-stop-session"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, ["session.exited"]); + + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.stopSession(threadId); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const exited = findThreadEvent(tracker.events, threadId, "session.exited"); + assert.equal(exited?.payload.exitKind, "graceful"); + const hasSession = yield* adapter.hasSession(threadId); + assert.equal(hasSession, false); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("fails the turn when the gateway agent run errors", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ failAgent: true })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-agent-error"); + const tracker = yield* trackEvents(adapter.streamEvents, threadId, [ + "turn.completed", + "runtime.error", + ]); + yield* adapter.startSession(startSessionInput(threadId)); + yield* adapter.sendTurn({ threadId, input: "fail", attachments: [] }); + yield* Deferred.await(tracker.done); + yield* Fiber.interrupt(tracker.fiber); + + const completed = findThreadEvent(tracker.events, threadId, "turn.completed"); + assert.equal(completed?.payload.state, "failed"); + assert.equal(completed?.payload.errorMessage, "mock agent failure"); + + const runtimeError = findThreadEvent(tracker.events, threadId, "runtime.error"); + assert.equal(runtimeError?.payload.message, "mock agent failure"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("fails startSession when the gateway rejects the connection", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ rejectConnect: true })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-reject-connect"); + + const error = yield* Effect.flip(adapter.startSession(startSessionInput(threadId))); + assert.equal(error._tag, "ProviderAdapterRequestError"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("fails startSession when the gateway cannot create the session", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ failSessionCreate: true })); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-session-create-fail"); + + const error = yield* Effect.flip(adapter.startSession(startSessionInput(threadId))); + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.equal(error.method, "sessions.create"); + } + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("rejects free-text user input with a validation error", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-user-input"); + + yield* adapter.startSession(startSessionInput(threadId)); + const error = yield* Effect.flip( + adapter.respondToUserInput(threadId, ApprovalRequestId.make("req-1"), {}), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("rejects turn rollback with a validation error", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-rollback"); + + yield* adapter.startSession(startSessionInput(threadId)); + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("rejects startSession when the provider mismatches", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter("ws://127.0.0.1:1"); + const threadId = ThreadId.make("openclaw-provider-mismatch"); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + }), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("rejects startSession when cwd is missing", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter("ws://127.0.0.1:1"); + const threadId = ThreadId.make("openclaw-cwd-missing"); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("openclaw"), + cwd: " ", + runtimeMode: "full-access", + }), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); + +it.live("rejects sendTurn with empty input", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const adapter = yield* makeTestAdapter(mock.url); + const threadId = ThreadId.make("openclaw-empty-turn"); + + yield* adapter.startSession(startSessionInput(threadId)); + const error = yield* Effect.flip(adapter.sendTurn({ threadId, input: " ", attachments: [] })); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawAdapterTestLayer)), +); diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.ts b/apps/server/src/provider/Layers/OpenClawAdapter.ts new file mode 100644 index 00000000000..9fdda532d09 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.ts @@ -0,0 +1,1129 @@ +/** + * OpenClawAdapter — per-instance OpenClaw adapter. + * + * Maps the OpenClaw Gateway WebSocket protocol onto the canonical + * `ProviderRuntimeEvent` stream. One Gateway connection is shared by every + * thread of this instance (see {@link OpenClawGatewayHolder}); each T3 thread + * is bound to its own gateway **session** keyed by `sessions.create`, and that + * key is the durable resume cursor. + * + * Design decisions: + * + * - **Sessions.** `sessions.create` (key `t3-`) mints one gateway + * session per T3 thread. Resuming re-adopts the stored key. Session records + * live in the gateway, so `stopSession` only aborts in-flight work and + * drops the local context — the gateway transcript survives for resume. + * - **Send.** The `agent` RPC with `sessionKey`, `cwd`, `idempotencyKey`, and + * optional `model`/`thinking`. It returns an immediate `status:"accepted"` + * ack with the run id; streamed `agent` events carry the deltas + * (`stream:"assistant"|"thinking"`), tool lifecycles (`stream:"tool"`), + * approvals (`stream:"approval"`), and the terminal lifecycle + * (`stream:"lifecycle"`, phase `start|end|error`). The final completion + * `res` for the same request id arrives later as a `response` event and is + * used as a fallback terminal. + * - **Steering.** A `sendTurn` while a run is active reuses the active turn id + * and sends into the same session; the gateway queues it into the running + * lane. + * - **Interrupts.** `chat.abort {sessionKey, runId}` cancels the active run; + * the lifecycle `end` then completes the turn as `interrupted`. + * - **Approvals.** OpenClaw's tool-approval surface (`exec.approval.*`, + * `operator.approvals` scope) maps onto `request.opened`/`request.resolved`; + * `respondToRequest` resolves via `exec.approval.resolve {id, decision}`. + * `respondToUserInput` fails cleanly — OpenClaw has no free-text input RPC. + * - **rollback.** Not supported (OpenClaw rewinds/branches by transcript entry + * id, not turn count); `rollbackThread` fails with a validation error. + * + * @module provider/Layers/OpenClawAdapter + */ +import { + ApprovalRequestId, + EventId, + type OpenClawSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; +import { + openClawRuntimeErrorDetail, + type OpenClawGatewayConnection, + type OpenClawGatewayEvent, + OpenClawRuntime, + type OpenClawRuntimeError, +} from "../openclawRuntime.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("openclaw"); + +/** + * Version tag stamped into the OpenClaw resume cursor. Bump if the cursor + * shape changes so stale cursors written by older builds are ignored. + */ +const OPENCLAW_RESUME_VERSION = 1 as const; + +export interface OpenClawAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + /** Isolated state dir for spawned gateways (see openclawRuntime). */ + readonly stateDir?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Shared gateway connection holder. The driver creates one per instance and + * hands it to both the adapter and text generation so a single gateway is + * spawned (or connected) lazily and shared by all consumers. + */ + readonly gateway: OpenClawGatewayHolder; +} + +export interface OpenClawGatewayHolder { + /** + * Resolve the instance gateway connection: connect to `gatewayUrl` when + * configured, otherwise spawn a gateway process. The connection is cached + * for the holder's lifetime and released when the driver's scope closes. + */ + readonly acquire: (input: { + readonly binaryPath: string; + readonly gatewayUrl?: string; + readonly gatewayToken?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly stateDir?: string; + readonly launchArgs?: ReadonlyArray; + }) => Effect.Effect; +} + +/** + * Build the driver-owned gateway holder. The connection's process lifetime is + * bound to `gatewayScope`, which the caller (driver) closes on teardown. + */ +export const makeOpenClawGatewayHolder = ( + gatewayScope: Scope.Scope, +): Effect.Effect => + Effect.gen(function* () { + const openClawRuntime = yield* OpenClawRuntime; + const cached = yield* Ref.make>(Option.none()); + const mutex = yield* Semaphore.make(1); + return { + acquire: (input) => + mutex.withPermit( + Effect.gen(function* () { + const existing = yield* Ref.get(cached); + if (Option.isSome(existing)) { + return existing.value; + } + const connection = yield* openClawRuntime + .connectToOpenClawGateway({ + binaryPath: input.binaryPath, + ...(input.gatewayUrl?.trim() ? { gatewayUrl: input.gatewayUrl } : {}), + ...(input.gatewayToken?.trim() ? { gatewayToken: input.gatewayToken } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + ...(input.stateDir !== undefined ? { stateDir: input.stateDir } : {}), + ...(input.launchArgs !== undefined ? { launchArgs: input.launchArgs } : {}), + }) + .pipe(Effect.provideService(Scope.Scope, gatewayScope)); + yield* Ref.set(cached, Option.some(connection)); + return connection; + }), + ), + }; + }); + +/** + * Decode a persisted resume cursor into the gateway session key. Anything + * that isn't a current-version cursor with a non-empty key means "no resume". + */ +export function parseOpenClawResume(raw: unknown): { readonly sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== OPENCLAW_RESUME_VERSION) { + return undefined; + } + if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { + return undefined; + } + return { sessionId: record.sessionId.trim() }; +} + +/** + * Whether an error definitively reports a missing gateway session. Only a + * confirmed miss may silently start a fresh session; other failures must + * propagate so a transient blip never resets a live thread. Decides on + * structured signals only (a `NOT_FOUND`-family error code). + */ +export function isOpenClawSessionNotFound(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null) { + return false; + } + const record = cause as Record; + const code = record.code; + if (code === "NOT_FOUND" || code === "SESSION_NOT_FOUND") { + return true; + } + const details = record.details; + if (typeof details === "object" && details !== null) { + const detailCode = (details as Record).code; + if (detailCode === "NOT_FOUND" || detailCode === "SESSION_NOT_FOUND") { + return true; + } + } + // The runtime wraps RPC failures in `OpenClawRuntimeError` and keeps the + // structured gateway error (with the `code`) in `cause`. Recurse so a + // NOT_FOUND-failure delivered through the runtime is still recognized. + const nested = record.cause; + if (typeof nested === "object" && nested !== null) { + return isOpenClawSessionNotFound(nested); + } + return false; +} + +interface OpenClawSessionContext { + readonly threadId: ThreadId; + readonly sessionKey: string; + session: ProviderSession; + activeTurnId: TurnId | undefined; + activeRunId: string | undefined; + readonly interruptedTurnIds: Set; + /** T3 request id → gateway approval id, for respondToRequest. */ + readonly pendingApprovals: Map; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function toCanonicalItemType( + toolName: string | undefined, +): Extract["payload"]["itemType"] { + const name = toolName?.toLowerCase().trim() ?? ""; + if (/^(bash|sh|shell|exec|run|command)/.test(name)) return "command_execution"; + if (/^(write|edit|patch|apply|fs\.)/.test(name)) return "file_change"; + if (/^mcp/.test(name)) return "mcp_tool_call"; + if (/^(web|search|fetch)/.test(name)) return "web_search"; + return "unknown"; +} + +function itemTitleForTool( + itemType: ReturnType, + toolName: string | undefined, +): string | undefined { + if (itemType === "command_execution") return "Ran command"; + if (itemType === "file_change") return "File change"; + if (itemType === "mcp_tool_call") return "MCP tool call"; + if (itemType === "web_search") return "Web search"; + return trimText(toolName) ? `Tool: ${toolName}` : undefined; +} + +function mapApprovalRequestType( + kind: unknown, +): Extract["payload"]["requestType"] { + if (kind === "exec") return "command_execution_approval"; + return "unknown"; +} + +function toApprovalDecision( + decision: "accept" | "acceptForSession" | "decline" | "cancel", +): string { + switch (decision) { + case "accept": + case "acceptForSession": + return "allow"; + case "decline": + case "cancel": + default: + return "deny"; + } +} + +function parseLaunchArgs(launchArgs: string | undefined): ReadonlyArray | undefined { + if (!launchArgs || launchArgs.trim().length === 0) { + return undefined; + } + const args = launchArgs + .trim() + .split(/\s+/) + .filter((arg) => arg.length > 0); + return args.length > 0 ? args : undefined; +} + +export const makeOpenClawAdapter = Effect.fn("makeOpenClawAdapter")(function* ( + openClawSettings: OpenClawSettings, + options: OpenClawAdapterLiveOptions, +) { + const boundInstanceId = options.instanceId ?? ProviderInstanceId.make("openclaw"); + const serverConfig = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + const nativeEventLogger = options.nativeEventLogger; + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + const lifecycleScope = yield* Scope.make(); + const eventPumpFiberRef = yield* Ref.make | undefined>(undefined); + + 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 OpenClaw runtime identifier.", + cause, + }), + ), + ); + const makeEventStamp = () => + Effect.all({ + eventId: Effect.map(randomUUIDv4, EventId.make), + createdAt: nowIso, + }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + type GatewayAcquireInput = Parameters[0]; + const gatewayAcquireInput = (): GatewayAcquireInput => { + const launchArgs = parseLaunchArgs(openClawSettings.launchArgs); + const input: GatewayAcquireInput = { + binaryPath: openClawSettings.binaryPath, + ...(openClawSettings.gatewayUrl?.trim() ? { gatewayUrl: openClawSettings.gatewayUrl } : {}), + ...(openClawSettings.gatewayToken?.trim() + ? { gatewayToken: openClawSettings.gatewayToken } + : {}), + ...(options.environment !== undefined ? { environment: options.environment } : {}), + ...(options.stateDir !== undefined ? { stateDir: options.stateDir } : {}), + }; + return launchArgs !== undefined ? { ...input, launchArgs } : input; + }; + + const acquireGateway = (method: string, threadId: ThreadId) => + options.gateway.acquire(gatewayAcquireInput()).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: cause.detail, + cause, + }), + ), + ); + + const requireSession = Effect.fn("requireSession")(function* (threadId: ThreadId) { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + return ctx; + }); + + const resetSessionToReady = (ctx: OpenClawSessionContext) => + Effect.gen(function* () { + ctx.activeTurnId = undefined; + ctx.activeRunId = undefined; + ctx.session = { + ...ctx.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + }); + + const emitTurnCompleted = ( + ctx: OpenClawSessionContext, + state: "completed" | "failed" | "interrupted" | "cancelled", + options?: { readonly errorMessage?: string }, + ) => + Effect.gen(function* () { + const turnId = ctx.activeTurnId; + if (!turnId) { + return; + } + const interrupted = ctx.interruptedTurnIds.has(turnId); + ctx.interruptedTurnIds.delete(turnId); + const terminalState = state === "completed" && interrupted ? "interrupted" : state; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + payload: { + state: terminalState, + ...(options?.errorMessage ? { errorMessage: options.errorMessage } : {}), + }, + }); + yield* resetSessionToReady(ctx); + }); + + const markSessionsClosed = (reason: string) => + Effect.gen(function* () { + for (const ctx of Array.from(sessions.values())) { + if (ctx.stopped) { + continue; + } + ctx.stopped = true; + sessions.delete(ctx.threadId); + const turnId = ctx.activeTurnId; + if (turnId) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + payload: { state: "failed", errorMessage: reason }, + }); + } + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + payload: { reason, exitKind: "error", recoverable: false }, + }); + } + }); + + const handleAgentEvent = Effect.fn("handleAgentEvent")(function* ( + ctx: OpenClawSessionContext, + runId: string, + stream: string, + data: Record, + ) { + const turnId = ctx.activeTurnId; + const base = { + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + ...(turnId ? { turnId } : {}), + }; + switch (stream) { + case "lifecycle": { + const phase = asString(data.phase); + if (phase === "end") { + yield* emitTurnCompleted(ctx, "completed"); + } else if (phase === "error") { + const message = + trimText(data.error) ?? trimText(data.message) ?? "OpenClaw agent run failed."; + yield* emitTurnCompleted(ctx, "failed", { errorMessage: message }); + yield* offerRuntimeEvent({ + type: "runtime.error", + ...(yield* makeEventStamp()), + ...base, + payload: { message, class: "provider_error", detail: data }, + }); + } + return; + } + case "assistant": { + const delta = trimText(data.delta) ?? trimText(data.text); + if (!delta) { + return; + } + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + ...base, + payload: { streamKind: "assistant_text", delta }, + }); + return; + } + case "thinking": { + const delta = trimText(data.delta) ?? trimText(data.text); + if (!delta) { + return; + } + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + ...base, + payload: { streamKind: "reasoning_text", delta }, + }); + return; + } + case "tool": { + // The gateway tool stream shape is not part of the documented + // protocol schema; map defensively and skip unknown shapes. + const state = asString(data.state); + const toolName = asString(data.toolName); + if (!state || !toolName) { + return; + } + const itemType = toCanonicalItemType(toolName); + const itemId = asString(data.toolCallId) ?? `${toolName}-${runId}`; + const title = itemTitleForTool(itemType, toolName); + const payload = { + itemType, + ...(title ? { title } : {}), + ...(data.result !== undefined ? { data: data.result } : {}), + }; + const normalized = state.toLowerCase(); + if (normalized === "start" || normalized === "running" || normalized === "pending") { + yield* offerRuntimeEvent({ + type: "item.started", + ...(yield* makeEventStamp()), + ...base, + itemId: RuntimeItemId.make(itemId), + payload: { ...payload, status: "inProgress" }, + }); + } else if (normalized === "end" || normalized === "completed") { + yield* offerRuntimeEvent({ + type: "item.completed", + ...(yield* makeEventStamp()), + ...base, + itemId: RuntimeItemId.make(itemId), + payload: { + ...payload, + status: data.isError === true ? "failed" : "completed", + }, + }); + } else if (normalized === "update") { + yield* offerRuntimeEvent({ + type: "item.updated", + ...(yield* makeEventStamp()), + ...base, + itemId: RuntimeItemId.make(itemId), + payload, + }); + } + return; + } + case "approval": { + const phase = asString(data.phase); + if (phase === "requested") { + const approvalId = asString(data.approvalId) ?? asString(data.toolCallId) ?? runId; + const t3RequestId = ApprovalRequestId.make(approvalId); + ctx.pendingApprovals.set(t3RequestId, approvalId); + const detail = trimText(data.command) ?? trimText(data.title) ?? trimText(data.message); + yield* offerRuntimeEvent({ + type: "request.opened", + ...(yield* makeEventStamp()), + ...base, + requestId: RuntimeRequestId.make(approvalId), + payload: { + requestType: mapApprovalRequestType(data.kind), + ...(detail ? { detail } : {}), + args: data, + }, + }); + } else if (phase === "resolved") { + const approvalId = asString(data.approvalId) ?? asString(data.toolCallId); + if (approvalId) { + ctx.pendingApprovals.delete(ApprovalRequestId.make(approvalId)); + yield* offerRuntimeEvent({ + type: "request.resolved", + ...(yield* makeEventStamp()), + ...base, + requestId: RuntimeRequestId.make(approvalId), + payload: { + requestType: "unknown", + decision: asString(data.status) ?? "unknown", + }, + }); + } + } + return; + } + case "usage": { + const inputTokens = typeof data.inputTokens === "number" ? data.inputTokens : undefined; + const outputTokens = typeof data.outputTokens === "number" ? data.outputTokens : undefined; + const usedTokens = + typeof data.usedTokens === "number" + ? data.usedTokens + : inputTokens !== undefined && outputTokens !== undefined + ? inputTokens + outputTokens + : undefined; + if (usedTokens === undefined || usedTokens <= 0) { + return; + } + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + ...base, + payload: { + usage: { + usedTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + }, + }, + }); + return; + } + default: + return; + } + }); + + const handleGatewayEvent = Effect.fn("handleGatewayEvent")(function* ( + event: OpenClawGatewayEvent, + ) { + if (event.kind === "closed") { + yield* markSessionsClosed(event.reason); + return; + } + if (event.kind === "response") { + // Late final `res` for an agent request: fallback terminal when the + // lifecycle stream did not emit end/error. + const payload = isRecord(event.frame.payload) ? event.frame.payload : {}; + const runId = asString(payload.runId); + if (!runId) { + return; + } + const status = asString(payload.status); + for (const ctx of Array.from(sessions.values())) { + if (ctx.stopped || ctx.activeRunId !== runId) { + continue; + } + if (status === "ok") { + yield* emitTurnCompleted(ctx, "completed"); + } else if (status === "error") { + const message = + trimText(payload.error) ?? trimText(payload.summary) ?? "OpenClaw agent run failed."; + yield* emitTurnCompleted(ctx, "failed", { errorMessage: message }); + } + return; + } + return; + } + const frame = event.frame; + if (frame.event !== "agent") { + return; + } + const payload = isRecord(frame.payload) ? frame.payload : {}; + const runId = asString(payload.runId); + const stream = asString(payload.stream); + const data = isRecord(payload.data) ? payload.data : {}; + if (!runId || !stream) { + return; + } + for (const ctx of Array.from(sessions.values())) { + if (ctx.stopped || ctx.activeRunId !== runId) { + continue; + } + yield* handleAgentEvent(ctx, runId, stream, data); + return; + } + }); + + const startEventPump = Effect.fn("startEventPump")(function* ( + connection: OpenClawGatewayConnection, + ) { + const existing = yield* Ref.get(eventPumpFiberRef); + if (existing !== undefined) { + return; + } + const fiber = yield* Stream.runForEach(connection.events, handleGatewayEvent).pipe( + Effect.catch(() => Effect.void), + Effect.forkIn(lifecycleScope), + ); + yield* Ref.set(eventPumpFiberRef, fiber); + }); + + const stopSessionInternal = (ctx: OpenClawSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) { + return; + } + ctx.stopped = true; + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + payload: { exitKind: "graceful", reason: "Session stopped" }, + }); + }); + + const startSession: OpenClawAdapterShape["startSession"] = (input) => + 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 existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const resumeKey = parseOpenClawResume(input.resumeCursor)?.sessionId; + const connection = yield* acquireGateway("connect", input.threadId); + + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const modelSlug = modelSelection?.model; + const thinkingLevel = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + + const resolved = yield* Effect.gen(function* () { + if (resumeKey) { + const describeResult = yield* connection + .request("sessions.describe", { key: resumeKey }) + .pipe(Effect.result); + if (Result.isSuccess(describeResult)) { + return { key: resumeKey, created: false }; + } + if (isOpenClawSessionNotFound(describeResult.failure)) { + yield* Effect.logWarning( + `OpenClaw session '${resumeKey}' no longer exists; starting a fresh session.`, + ); + } else { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sessions.describe", + detail: openClawRuntimeErrorDetail(describeResult.failure), + cause: describeResult.failure, + }); + } + } + const key = `t3-${yield* randomUUIDv4}`; + const createParams: Record = { + key, + label: `T3 Code: ${cwd.split("/").filter(Boolean).pop() ?? cwd}`, + ...(modelSlug ? { model: modelSlug } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + }; + const created = yield* connection.request("sessions.create", createParams); + const payload = isRecord(created) ? created : {}; + if (payload.ok === false) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sessions.create", + detail: openClawRuntimeErrorDetail(payload), + cause: payload, + }); + } + const createdKey = asString(payload.key) ?? key; + return { key: createdKey, created: true }; + }).pipe( + Effect.mapError((cause) => + cause instanceof ProviderAdapterRequestError + ? cause + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sessions.create", + detail: openClawRuntimeErrorDetail(cause), + cause, + }), + ), + ); + + yield* startEventPump(connection); + + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(modelSlug ? { model: modelSlug } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: OPENCLAW_RESUME_VERSION, + sessionId: resolved.key, + }, + createdAt, + updatedAt: createdAt, + }; + const ctx: OpenClawSessionContext = { + threadId: input.threadId, + sessionKey: resolved.key, + session, + activeTurnId: undefined, + activeRunId: undefined, + interruptedTurnIds: new Set(), + pendingApprovals: new Map(), + stopped: false, + }; + sessions.set(input.threadId, ctx); + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { resume: resumeKey !== undefined }, + }); + yield* offerRuntimeEvent({ + type: "session.configured", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { + config: { + binaryPath: openClawSettings.binaryPath, + gatewayUrl: openClawSettings.gatewayUrl, + launchArgs: openClawSettings.launchArgs, + ...(modelSlug ? { model: modelSlug } : {}), + }, + }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { state: "ready", reason: "OpenClaw session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { providerThreadId: resolved.key }, + }); + + return session; + }); + + const sendTurn: OpenClawAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const ctx = yield* requireSession(input.threadId); + const connection = yield* acquireGateway("turn/start", input.threadId); + + const steering = ctx.activeTurnId !== undefined; + const turnId = + steering && ctx.activeTurnId + ? ctx.activeTurnId + : TurnId.make(`openclaw-turn-${yield* randomUUIDv4}`); + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const text = input.input?.trim(); + if (!text) { + yield* resetSessionToReady(ctx); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text.", + }); + } + + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const modelSlug = modelSelection?.model ?? ctx.session.model; + const thinkingLevel = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + + if (!steering) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: input.threadId, + turnId, + payload: { + ...(modelSlug ? { model: modelSlug } : {}), + ...(thinkingLevel ? { effort: thinkingLevel } : {}), + }, + }); + } + + const agentParams: Record = { + sessionKey: ctx.sessionKey, + message: text, + idempotencyKey: yield* randomUUIDv4, + cwd: ctx.session.cwd ?? serverConfig.cwd, + ...(modelSlug ? { model: modelSlug } : {}), + ...(thinkingLevel ? { thinking: thinkingLevel } : {}), + }; + + const result = yield* connection.request("agent", agentParams).pipe(Effect.result); + if (Result.isFailure(result)) { + const errorMessage = result.failure.detail; + yield* emitTurnCompleted(ctx, "failed", { errorMessage }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: errorMessage, + cause: result.failure, + }); + } + const payload = isRecord(result.success) ? result.success : {}; + const runId = asString(payload.runId); + if (runId) { + ctx.activeRunId = runId; + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: OpenClawAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + const target = turnId ?? activeTurnId; + if (!target) { + return; + } + ctx.interruptedTurnIds.add(target); + if (ctx.activeRunId) { + const connection = yield* acquireGateway("turn/interrupt", threadId); + yield* connection + .request("chat.abort", { + sessionKey: ctx.sessionKey, + runId: ctx.activeRunId, + }) + .pipe(Effect.ignore); + } + }); + + const respondToRequest: OpenClawAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const gatewayApprovalId = ctx.pendingApprovals.get(requestId); + if (!gatewayApprovalId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "exec.approval.resolve", + detail: `Unknown OpenClaw approval request '${requestId}'.`, + }); + } + const connection = yield* acquireGateway("exec.approval.resolve", threadId); + if (!connection.hello.scopes.includes("operator.approvals")) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: + "The OpenClaw gateway connection does not hold the operator.approvals scope; approvals cannot be resolved.", + }); + } + yield* connection + .request("exec.approval.resolve", { + id: gatewayApprovalId, + decision: toApprovalDecision(decision), + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "exec.approval.resolve", + detail: cause.detail, + cause, + }), + ), + ); + }); + + const respondToUserInput: OpenClawAdapterShape["respondToUserInput"] = ( + _threadId, + _requestId, + _answers: ProviderUserInputAnswers, + ) => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: + "OpenClaw has no free-text user-input RPC; tool approvals are answered through respondToRequest.", + }), + ); + + const readThread: OpenClawAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const connection = yield* acquireGateway("chat.history", threadId); + const history = yield* connection + .request("chat.history", { sessionKey: ctx.sessionKey }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "chat.history", + detail: cause.detail, + cause, + }), + ), + ); + const payload = isRecord(history) ? history : {}; + const messages = Array.isArray(payload.messages) ? payload.messages : []; + // Group the flat history into turns: each assistant row opens a turn and + // the rows before it attach to that turn. + const turns: Array<{ id: TurnId; items: Array }> = []; + let pendingUserItems: Array = []; + for (const message of messages) { + const record = isRecord(message) ? message : {}; + const role = asString(record.role); + if (role === "assistant") { + const id = + asString(record.id) ?? asString(record.messageId) ?? `openclaw-msg-${turns.length + 1}`; + turns.push({ id: TurnId.make(id), items: [...pendingUserItems, message] }); + pendingUserItems = []; + } else { + pendingUserItems.push(message); + } + } + return { threadId, turns }; + }); + + const rollbackThread: OpenClawAdapterShape["rollbackThread"] = () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: + "OpenClaw has no turn-count rollback (it rewinds/branches by transcript entry id); checkpoint revert restores the workspace.", + }), + ); + + const stopSession: OpenClawAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) { + return; + } + if (ctx.activeRunId) { + const connection = yield* acquireGateway("chat.abort", threadId).pipe(Effect.option); + if (Option.isSome(connection)) { + yield* connection.value + .request("chat.abort", { sessionKey: ctx.sessionKey, runId: ctx.activeRunId }) + .pipe(Effect.ignore); + } + } + yield* stopSessionInternal(ctx); + }); + + const listSessions: OpenClawAdapterShape["listSessions"] = () => + Effect.forEach( + Array.from(sessions.values()).filter((ctx) => !ctx.stopped), + (ctx) => Effect.succeed(ctx.session), + { concurrency: 1 }, + ); + + const hasSession: OpenClawAdapterShape["hasSession"] = (threadId) => + Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + + const stopAll: OpenClawAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { + concurrency: 1, + discard: true, + }).pipe(Effect.asVoid); + + yield* Effect.acquireRelease(Effect.void, () => + stopAll().pipe( + Effect.andThen(Scope.close(lifecycleScope, Exit.void)), + Effect.andThen(Queue.shutdown(runtimeEventQueue)), + Effect.andThen(nativeEventLogger?.close() ?? Effect.void), + Effect.ignore, + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "unsupported", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, + } satisfies OpenClawAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.test.ts b/apps/server/src/provider/Layers/OpenClawProvider.test.ts new file mode 100644 index 00000000000..78129a75cf5 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.test.ts @@ -0,0 +1,395 @@ +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 Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { OpenClawSettings } from "@t3tools/contracts"; + +import { OpenClawRuntimeLive } from "../openclawRuntime.ts"; +import { startMockOpenClawGateway } from "../testUtils/openclawMockGateway.ts"; +import { + checkOpenClawProviderStatus, + makePendingOpenClawProvider, + openClawDiscoveredModelsFromCatalog, + parseOpenClawModelsList, +} from "./OpenClawProvider.ts"; + +const decodeOpenClawSettings = Schema.decodeSync(OpenClawSettings); + +const openClawProviderTestLayer = NodeServices.layer.pipe( + Layer.provideMerge(OpenClawRuntimeLive.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer)))), +); + +/** Writes a fake `openclaw` CLI into the current scope's temp directory. */ +const writeFakeOpenClawCli = (lines: ReadonlyArray) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-openclaw-provider-" }); + const binaryPath = path.join(dir, "openclaw"); + yield* fs.writeFileString(binaryPath, lines.join("\n")); + yield* fs.chmod(binaryPath, 0o755); + return binaryPath; + }); + +describe("makePendingOpenClawProvider", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenClawProvider( + decodeOpenClawSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("disabled"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + ]); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenClawProvider(decodeOpenClawSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking OpenClaw gateway availability"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + ]); + }), + ); + + it.effect("appends custom models to the static catalog", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenClawProvider( + decodeOpenClawSettings({ customModels: ["my-custom-model"] }), + ); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "my-custom-model", + ]); + expect(snapshot.models[2]?.isCustom).toBe(true); + }), + ); +}); + +describe("parseOpenClawModelsList", () => { + it("parses a models array of objects and strings", () => { + const entries = parseOpenClawModelsList({ + models: [{ id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, "openai/gpt-5"], + }); + expect(entries).toEqual([ + { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "openai/gpt-5" }, + ]); + }); + + it("parses a catalog array", () => { + const entries = parseOpenClawModelsList({ + catalog: [{ id: "anthropic/claude-haiku-4-5" }], + }); + expect(entries).toEqual([{ id: "anthropic/claude-haiku-4-5" }]); + }); + + it("parses the verified CLI envelope (key entries) and drops unavailable models", () => { + // Shape verified against `openclaw models list --json` (2026.7.1). + const entries = parseOpenClawModelsList({ + count: 3, + models: [ + { + key: "openrouter/thinkingmachines/inkling-small", + name: "thinkingmachines/inkling-small", + available: true, + missing: false, + tags: ["default", "configured"], + }, + { + key: "deepseek/deepseek-v4-flash-0731", + name: "deepseek/deepseek-v4-flash-0731", + available: false, + missing: false, + tags: ["configured"], + }, + { + key: "openai/gpt-5.6-luna", + name: "gpt-5.6-luna", + available: true, + missing: true, + tags: ["configured"], + }, + ], + }); + expect(entries).toEqual([ + { id: "openrouter/thinkingmachines/inkling-small", name: "thinkingmachines/inkling-small" }, + ]); + }); + + it("parses default.models", () => { + const entries = parseOpenClawModelsList({ + default: { models: [{ id: "gpt-5", name: "GPT-5" }] }, + }); + expect(entries).toEqual([{ id: "gpt-5", name: "GPT-5" }]); + }); + + it("skips entries without an id", () => { + const entries = parseOpenClawModelsList({ + models: [{ id: " " }, { name: "No id" }, { id: "gpt-5" }], + }); + expect(entries).toEqual([{ id: "gpt-5" }]); + }); + + it("returns undefined when nothing parses", () => { + expect(parseOpenClawModelsList(undefined)).toBeUndefined(); + expect(parseOpenClawModelsList("garbage")).toBeUndefined(); + expect(parseOpenClawModelsList({})).toBeUndefined(); + expect(parseOpenClawModelsList({ models: [] })).toBeUndefined(); + expect(parseOpenClawModelsList({ models: "not-an-array" })).toBeUndefined(); + }); +}); + +describe("openClawDiscoveredModelsFromCatalog", () => { + it("maps entries to ServerProviderModels and dedupes by slug", () => { + const models = openClawDiscoveredModelsFromCatalog([ + { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "anthropic/claude-sonnet-4-6" }, + { id: "gpt-5" }, + ]); + expect(models.map((model) => model.slug)).toEqual(["anthropic/claude-sonnet-4-6", "gpt-5"]); + expect(models[0]?.isCustom).toBe(false); + expect(models[1]?.name).toBe("gpt-5"); + }); + + it("filters out empty slugs", () => { + const models = openClawDiscoveredModelsFromCatalog([ + { id: " " }, + { id: "gpt-5", name: "GPT-5" }, + ]); + expect(models.map((model) => model.slug)).toEqual(["gpt-5"]); + }); +}); + +describe("OpenClaw reasoning capabilities", () => { + it("advertises a reasoningEffort select descriptor on built-in models", () => + Effect.gen(function* () { + const snapshot = yield* makePendingOpenClawProvider(decodeOpenClawSettings({})); + for (const model of snapshot.models) { + const descriptors = model.capabilities?.optionDescriptors ?? []; + const reasoning = descriptors.find((descriptor) => descriptor.id === "reasoningEffort"); + expect(reasoning).toBeDefined(); + expect(reasoning?.type).toBe("select"); + if (reasoning?.type === "select") { + expect(reasoning.options.map((option) => option.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(reasoning.options.find((option) => option.isDefault)?.id).toBe("medium"); + } + } + })); + + it("advertises the reasoningEffort descriptor on discovered catalog models", () => { + const models = openClawDiscoveredModelsFromCatalog([ + { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]); + const reasoning = models[0]?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoningEffort", + ); + expect(reasoning?.type).toBe("select"); + }); +}); + +describe("checkOpenClawProviderStatus", () => { + it.live("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.message).toContain("disabled"); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/openclaw", + }), + ); + 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/); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("reports ready with the CLI version and falls back when models list fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeFakeOpenClawCli([ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "openclaw 1.2.3\\n"', + " exit 0", + "fi", + 'if [ "$1" = "models" ]; then', + ' printf "unexpected output format\\n" >&2', + " exit 1", + "fi", + "exit 1", + "", + ]); + return yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: true, binaryPath }), + ); + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.message).toBe("OpenClaw v1.2.3 is available."); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + ]); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("uses discovered models when models list parses", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeFakeOpenClawCli([ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "openclaw 1.2.3\\n"', + " exit 0", + "fi", + 'if [ "$1" = "models" ]; then', + ' printf \'{"models":[{"id":"anthropic/claude-sonnet-4-6","name":"Claude Sonnet 4.6"}]}\\n\'', + " exit 0", + "fi", + "exit 1", + "", + ]); + return yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: true, binaryPath }), + ); + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["anthropic/claude-sonnet-4-6"]); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("appends custom models to the discovered or fallback catalog", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeFakeOpenClawCli([ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "openclaw 1.2.3\\n"', + " exit 0", + "fi", + 'if [ "$1" = "models" ]; then', + ' printf "unexpected output format\\n" >&2', + " exit 1", + "fi", + "exit 1", + "", + ]); + return yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ + enabled: true, + binaryPath, + customModels: ["my-custom-model"], + }), + ); + }), + ); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "my-custom-model", + ]); + expect(snapshot.models[2]?.isCustom).toBe(true); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeFakeOpenClawCli([ + "#!/bin/sh", + 'printf "broken openclaw install\\n" >&2', + "exit 2", + "", + ]); + return yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: true, binaryPath }), + ); + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Failed to execute the OpenClaw CLI health check."); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("connects to a configured external gateway and reports ready", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const snapshot = yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: true, gatewayUrl: mock.url }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("2026.8.1"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.message).toBe("Connected to the OpenClaw gateway (v2026.8.1)."); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + ]); + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); + + it.live("reports an error snapshot when the configured gateway is unreachable", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const url = mock.url; + yield* Effect.promise(() => mock.close()); + const snapshot = yield* checkOpenClawProviderStatus( + decodeOpenClawSettings({ enabled: true, gatewayUrl: url }), + ); + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.message).toBe( + "Couldn't reach the configured OpenClaw gateway. Check the Gateway URL and token.", + ); + }).pipe(Effect.provide(openClawProviderTestLayer)), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.ts b/apps/server/src/provider/Layers/OpenClawProvider.ts new file mode 100644 index 00000000000..528f7589a53 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.ts @@ -0,0 +1,463 @@ +import { type OpenClawSettings, type ServerProviderModel } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Cause from "effect/Cause"; +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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +/** + * OpenClaw exposes a per-session "thinking level" that the adapter forwards + * to the gateway (`sessions.create { thinkingLevel }`, `agent { thinking }`). + * The adapter already reads a `reasoningEffort` selection, so advertising the + * descriptor here makes the composer surface the toggle. The vocabulary mirrors + * PiAgent (the only other runtime with a `thinkingLevel`/`setThinkingLevel` + * wire shape); values are forwarded verbatim, so levels the gateway rejects + * surface as gateway errors rather than silent no-ops. + */ +const OPENCLAW_REASONING_OPTIONS = [ + { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, +]; + +function openClawModelCapabilities() { + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: OPENCLAW_REASONING_OPTIONS, + }), + ], + }); +} +import { + OpenClawRuntime, + openClawRuntimeErrorDetail, + type OpenClawGatewayConnection, + type OpenClawRuntimeShape, +} from "../openclawRuntime.ts"; + +const OPENCLAW_PRESENTATION = { + displayName: "OpenClaw", + showInteractionModeToggle: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_PROBE_TIMEOUT_MS = 10_000; + +/** + * Static catalog used when the gateway/CLI model catalog cannot be probed. + * The default session model and default textgen model come from the shared + * contracts; custom models from settings are always appended on top. + */ +const OPENCLAW_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: openClawModelCapabilities(), + }, + { + slug: "anthropic/claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: openClawModelCapabilities(), + }, +]; + +function openClawModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = OPENCLAW_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], openClawModelCapabilities()); +} + +export function makePendingOpenClawProvider( + openClawSettings: OpenClawSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = openClawModelsFromSettings(openClawSettings.customModels); + + if (!openClawSettings.enabled) { + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenClaw gateway availability...", + }, + }); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Parse the gateway `models.list` payload defensively. The CLI envelope is + * verified against `openclaw models list --json` (2026.7.1): entries carry + * `key` (not `id`) plus `available`/`missing` flags, e.g. + * `{count, models: [{key, name, available, missing, tags}]}`. The gateway + * envelope is not pinned by the public docs, so we still accept several + * plausible shapes (`models`, `catalog`, `default.models`, `id` or `key`) + * and return `undefined` when nothing parses (the caller falls back to the + * static catalog). Entries explicitly flagged unavailable or missing are + * dropped: the gateway refuses them at session time. + */ +export function parseOpenClawModelsList( + payload: unknown, +): ReadonlyArray<{ readonly id: string; readonly name?: string }> | undefined { + const collect = (value: unknown): ReadonlyArray<{ id: string; name?: string }> => { + if (!Array.isArray(value)) { + return []; + } + const entries: Array<{ id: string; name?: string }> = []; + for (const entry of value) { + if (typeof entry === "string") { + const id = entry.trim(); + if (id) { + entries.push({ id }); + } + continue; + } + if (isRecord(entry)) { + if (entry.available === false || entry.missing === true) { + continue; + } + const rawId = typeof entry.id === "string" ? entry.id : entry.key; + const id = typeof rawId === "string" ? rawId.trim() : undefined; + if (id) { + entries.push({ + id, + ...(typeof entry.name === "string" && entry.name.trim() + ? { name: entry.name.trim() } + : {}), + }); + } + } + } + return entries; + }; + + if (!isRecord(payload)) { + return undefined; + } + for (const key of ["models", "catalog"] as const) { + const entries = collect(payload[key]); + if (entries.length > 0) { + return entries; + } + } + const def = payload.default; + if (isRecord(def)) { + const entries = collect(def.models); + if (entries.length > 0) { + return entries; + } + } + return undefined; +} + +export function openClawDiscoveredModelsFromCatalog( + entries: ReadonlyArray<{ readonly id: string; readonly name?: string }>, +): ReadonlyArray { + const seen = new Set(); + return entries + .map((entry): ServerProviderModel | undefined => { + const slug = entry.id.trim(); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: entry.name?.trim() || slug, + isCustom: false, + capabilities: openClawModelCapabilities(), + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +/** + * Probe the configured external gateway over the WebSocket control plane: + * handshake (version) + `models.list`. The connection is scoped to the probe. + */ +const probeExternalGateway = ( + openClawRuntime: OpenClawRuntimeShape, + openClawSettings: OpenClawSettings, + environment: NodeJS.ProcessEnv, +): Effect.Effect< + { readonly version: string; readonly models: ReadonlyArray }, + unknown +> => + Effect.scoped( + Effect.gen(function* () { + const connection = yield* openClawRuntime.connectToOpenClawGateway({ + binaryPath: openClawSettings.binaryPath, + gatewayUrl: openClawSettings.gatewayUrl, + ...(openClawSettings.gatewayToken?.trim() + ? { gatewayToken: openClawSettings.gatewayToken } + : {}), + environment, + }); + const catalogResult = yield* loadGatewayModels(connection).pipe(Effect.result); + const discovered = Result.isSuccess(catalogResult) + ? openClawDiscoveredModelsFromCatalog(catalogResult.success) + : []; + return { + version: connection.hello.serverVersion, + models: + discovered.length > 0 + ? openClawModelsFromSettings(openClawSettings.customModels, discovered) + : openClawModelsFromSettings(openClawSettings.customModels), + }; + }), + ); + +const loadGatewayModels = ( + connection: OpenClawGatewayConnection, +): Effect.Effect, unknown> => + connection + .request("models.list", {}) + .pipe(Effect.map((payload) => parseOpenClawModelsList(payload) ?? [])); + +/** + * Parse a `--json` CLI payload defensively: try the stdout as JSON, then as a + * JSON object embedded after the first `{`/`[`. Returns `undefined` when + * nothing parses. + */ +function parseCliJsonPayload(stdout: string): unknown { + const trimmed = stdout.trim(); + if (!trimmed) { + return undefined; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + // Fall through to the substring probe. + } + const start = trimmed.search(/[[{]/); + if (start < 0) { + return undefined; + } + try { + return JSON.parse(trimmed.slice(start)) as unknown; + } catch { + return undefined; + } +} + +const runOpenClawModelsCli = ( + binaryPath: string, + environment: NodeJS.ProcessEnv, +): Effect.Effect< + { readonly code: number; readonly payload: unknown }, + unknown, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(binaryPath, ["models", "list", "--json"], { + env: environment, + }); + const result = yield* spawnAndCollect( + binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + return { code: result.code, payload: parseCliJsonPayload(result.stdout) }; + }); + +export const checkOpenClawProviderStatus = Effect.fn("checkOpenClawProviderStatus")(function* ( + openClawSettings: OpenClawSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenClawRuntime | ChildProcessSpawner.ChildProcessSpawner +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = openClawModelsFromSettings(openClawSettings.customModels); + const isExternalGateway = openClawSettings.gatewayUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const lower = openClawRuntimeErrorDetail(cause).toLowerCase(); + const missingCommand = + isCommandMissingCause(cause) || lower.includes("enoent") || lower.includes("notfound"); + const message = missingCommand + ? "OpenClaw CLI (`openclaw`) is not installed or not on PATH." + : isExternalGateway + ? "Couldn't reach the configured OpenClaw gateway. Check the Gateway URL and token." + : "Failed to execute the OpenClaw CLI health check."; + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: openClawSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !missingCommand, + version, + status: "error", + auth: { status: "unknown" }, + message, + }, + }); + }; + + if (!openClawSettings.enabled) { + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }, + }); + } + + const openClawRuntime = yield* OpenClawRuntime; + + if (isExternalGateway) { + const probeExit = yield* Effect.exit( + probeExternalGateway(openClawRuntime, openClawSettings, environment), + ); + if (Exit.isFailure(probeExit)) { + return fallback(Cause.squash(probeExit.cause)); + } + const { version, models } = probeExit.value; + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated", type: "openclaw-gateway" }, + message: `Connected to the OpenClaw gateway (v${version}).`, + }, + }); + } + + // Spawned-gateway path: probe the CLI instead of starting a full gateway. + const versionResult = yield* openClawRuntime + .runOpenClawCommand({ + binaryPath: openClawSettings.binaryPath, + args: ["--version"], + environment, + }) + .pipe(Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionResult)) { + return fallback(versionResult.failure); + } + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: openClawSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw CLI is installed but timed out while running `openclaw --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return fallback( + new Error(`openclaw --version exited with code ${versionOutput.code}.`), + version, + ); + } + + // Model discovery via the CLI is best-effort; an unparseable `models list` + // falls back to the static catalog. + const modelsExit = yield* Effect.exit( + runOpenClawModelsCli(openClawSettings.binaryPath, environment).pipe( + Effect.timeoutOption(MODEL_PROBE_TIMEOUT_MS), + ), + ); + let discovered: ReadonlyArray<{ readonly id: string; readonly name?: string }> | undefined; + if (Exit.isSuccess(modelsExit)) { + if (Option.isSome(modelsExit.value)) { + const result = modelsExit.value.value; + if (result.code === 0) { + discovered = parseOpenClawModelsList(result.payload); + } + } + } + const discoveredModels = discovered ? openClawDiscoveredModelsFromCatalog(discovered) : []; + const models = + discoveredModels.length > 0 + ? openClawModelsFromSettings(openClawSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: openClawSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + message: `OpenClaw v${version ?? "?"} is available.`, + }, + }); +}); diff --git a/apps/server/src/provider/Layers/PiAgentAdapter.test.ts b/apps/server/src/provider/Layers/PiAgentAdapter.test.ts new file mode 100644 index 00000000000..518c053edfb --- /dev/null +++ b/apps/server/src/provider/Layers/PiAgentAdapter.test.ts @@ -0,0 +1,690 @@ +// @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 { + ApprovalRequestId, + PiAgentSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderSession, + type ProviderSessionStartInput, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { + isPiThinkingLevel, + makePiAgentAdapter, + resolvePiModelSelection, +} from "./PiAgentAdapter.ts"; +import { makePiRecordSplitter, parsePiResumeCursor } from "./PiAgentSessionRuntime.ts"; +const decodePiAgentSettings = Schema.decodeSync(PiAgentSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/pi-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockPiWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-mock-")); + const wrapperPath = NodePath.join(dir, "fake-pi.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +function waitForFileContent( + filePath: string, + attempts = 40, + expectedContent?: string, +): Effect.Effect { + const readAttempt = (remainingAttempts: number): Effect.Effect => + Effect.gen(function* () { + if (remainingAttempts <= 0) { + return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); + } + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if ( + raw.trim().length > 0 && + (expectedContent === undefined || raw.includes(expectedContent)) + ) { + return raw; + } + yield* Effect.sleep("25 millis"); + return yield* readAttempt(remainingAttempts - 1); + }); + return readAttempt(attempts); +} + +const piAgentAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-pi-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makePiAgentAdapter(decodePiAgentSettings({ binaryPath }), options).pipe(Effect.orDie); + +const startMockSession = ( + adapter: { + startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + }, + threadId: ThreadId, + extraEnv?: Record, + resumeCursor?: unknown, +) => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper(extraEnv)); + return yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("piAgent"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("piAgent"), + model: "anthropic/claude-sonnet-4-6", + }, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + }); + }); + +it("splits pi JSONL records on newlines only and strips a trailing CR", () => { + const splitter = makePiRecordSplitter(); + // A record without a trailing newline is held until the next chunk. + const records = splitter.push( + Buffer.from('{"type":"get_state"}\r\n{"type":"response","command":"get_state"', "utf8"), + ); + assert.deepEqual(records, ['{"type":"get_state"}']); + assert.deepEqual( + splitter.push(Buffer.from('}\n{"message":"line\\u2028sep\\u2029ok"}\n', "utf8")), + ['{"type":"response","command":"get_state"}', '{"message":"line\\u2028sep\\u2029ok"}'], + ); + assert.deepEqual(splitter.flush(), []); +}); + +it("parses the versioned resume cursor and ignores mismatches", () => { + assert.deepEqual(parsePiResumeCursor({ schemaVersion: 1, sessionId: "s1" }), { + sessionId: "s1", + }); + assert.isUndefined(parsePiResumeCursor({ schemaVersion: 2, sessionId: "s1" })); + assert.isUndefined(parsePiResumeCursor({ schemaVersion: 1 })); + assert.isUndefined(parsePiResumeCursor(undefined)); +}); + +it("resolves pi model slugs against the catalog", () => { + const catalog = [ + { id: "claude-sonnet-4-6", name: undefined, provider: "anthropic", api: undefined }, + ]; + assert.deepEqual(resolvePiModelSelection("anthropic/claude-sonnet-4-6", catalog), { + provider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + assert.deepEqual(resolvePiModelSelection("claude-sonnet-4-6", catalog), { + provider: "anthropic", + modelId: "claude-sonnet-4-6", + }); + assert.deepEqual(resolvePiModelSelection("unknown-bare-id", catalog), { + provider: "anthropic", + modelId: "unknown-bare-id", + }); +}); + +it("recognizes pi thinking levels", () => { + assert.isTrue(isPiThinkingLevel("medium")); + assert.isTrue(isPiThinkingLevel("xhigh")); + assert.isFalse(isPiThinkingLevel("ultra")); +}); + +it.layer(piAgentAdapterTestLayer)("PiAgentAdapter", (it) => { + it.effect("starts a session and maps a mock pi prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-mock-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + 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" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* startMockSession(adapter, threadId); + + assert.equal(session.provider, "piAgent"); + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-pi-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello pi", + 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", + "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 pi"); + } + + const tokenUsage = runtimeEvents.find((e) => e.type === "thread.token-usage.updated"); + assert.isDefined(tokenUsage); + if (tokenUsage?.type === "thread.token-usage.updated") { + assert.equal(tokenUsage.payload.usage.usedTokens, 150); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits reasoning deltas and tool item lifecycles from pi events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-tool-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ + T3_PI_EMIT_TOOL_CALLS: "1", + T3_PI_EMIT_THINKING: "1", + }), + ); + 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" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + yield* adapter.sendTurn({ threadId, input: "use tools", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const reasoning = runtimeEvents.find( + (e): e is Extract => + e.type === "content.delta" && e.payload.streamKind === "reasoning_text", + ); + assert.isDefined(reasoning); + assert.equal(reasoning?.payload.delta, "mock thinking"); + + const startedItem = runtimeEvents.find( + (e): e is Extract => + e.type === "item.started", + ); + assert.isDefined(startedItem); + assert.equal(startedItem?.payload.itemType, "command_execution"); + assert.equal(startedItem?.payload.title, "Ran command"); + + const completedItem = runtimeEvents.find( + (e): e is Extract => + e.type === "item.completed" && e.payload.itemType === "command_execution", + ); + assert.isDefined(completedItem); + assert.equal(completedItem?.payload.status, "completed"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("completes an aborted turn as interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-interrupt-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ T3_PI_HANG_PROMPT_FOREVER: "1" }), + ); + 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" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + const sendTurnResult = yield* adapter.sendTurn({ + threadId, + input: "hang forever", + attachments: [], + }); + yield* adapter.interruptTurn(threadId, sendTurnResult.turnId); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const completedEvent = runtimeEvents.find( + (e): e is Extract => + e.type === "turn.completed" && String(e.threadId) === String(threadId), + ); + assert.equal(completedEvent?.payload.state, "interrupted"); + + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((s) => s.threadId === threadId); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails the turn when pi rejects the prompt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-prompt-failure"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ T3_PI_FAIL_PROMPT: "1" }), + ); + 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" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + const error = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "fail prompt", attachments: [] }), + ); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + const completedEvent = runtimeEvents.find( + (e): e is Extract => + e.type === "turn.completed" && String(e.threadId) === String(threadId), + ); + assert.equal(completedEvent?.payload.state, "failed"); + assert.isString(completedEvent?.payload.errorMessage); + + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((s) => s.threadId === threadId); + assert.equal(readySession?.status, "ready"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces pi confirm requests as user input and answers them", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-confirm-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ T3_PI_EMIT_CONFIRM: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const requested = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) return; + if (event.type === "user-input.requested") { + yield* Deferred.succeed(requested, event).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "confirm this", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.payload.questions.length, 1); + assert.deepEqual( + requestedEvent.payload.questions[0]?.options.map((option) => option.label), + ["Allow", "Deny"], + ); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { [requestedEvent.payload.questions[0]?.id ?? "answer"]: "Allow" }, + ); + + yield* Deferred.await(turnCompleted); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(eventsFiber); + + const completedEvent = yield* adapter + .listSessions() + .pipe(Effect.map((sessions) => sessions.find((s) => s.threadId === threadId))); + assert.equal(completedEvent?.status, "ready"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("auto-cancels pi free-text input requests with a warning", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-input-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper({ T3_PI_EMIT_INPUT: "1" })); + 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" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + yield* adapter.sendTurn({ threadId, input: "type something", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const warning = runtimeEvents.find( + (e): e is Extract => + e.type === "runtime.warning" && e.payload.message.includes("free-text"), + ); + assert.isDefined(warning); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces pi notify extension requests as warnings", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-notify-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ T3_PI_EMIT_NOTIFY: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const warning = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "runtime.warning" && String(event.threadId) === String(threadId) + ? Deferred.succeed(warning, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + yield* adapter.sendTurn({ threadId, input: "notify me", attachments: [] }); + const warningEvent = yield* Deferred.await(warning); + assert.equal(warningEvent.payload.message, "A notification"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects rollback with a validation error", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-rollback-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* startMockSession(adapter, threadId); + + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects approval responses because pi has no permission system", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-approval-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* startMockSession(adapter, threadId); + + const error = yield* Effect.flip( + adapter.respondToRequest(threadId, ApprovalRequestId.make("req-1"), "accept"), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reads the pi thread back from get_messages", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-read-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* startMockSession(adapter, threadId); + + const snapshot = yield* adapter.readThread(threadId); + assert.equal(snapshot.turns.length, 1); + assert.equal(snapshot.turns[0]?.items.length, 2); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes a session from a versioned resume cursor", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-resume-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const sessionStarted = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.started" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sessionStarted, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + const session = yield* startMockSession(adapter, threadId, undefined, { + schemaVersion: 1, + sessionId: "mock-pi-session-1", + }); + const startedEvent = yield* Deferred.await(sessionStarted); + + assert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-pi-session-1", + }); + assert.equal(startedEvent.payload.resume, true); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("steers into the active turn when a second prompt arrives mid-run", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-steer-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ T3_PI_DELAY_SETTLE_MS: "400" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const contentDelta = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const turnCompletedCount = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) return; + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startMockSession(adapter, threadId); + const firstSendFiber = yield* adapter + .sendTurn({ threadId, input: "first prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta); + + const steerResult = yield* adapter.sendTurn({ + threadId, + input: "steer prompt", + attachments: [], + }); + const firstResult = yield* Fiber.join(firstSendFiber); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(eventsFiber); + + assert.equal(String(steerResult.turnId), String(firstResult.turnId)); + + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((s) => s.threadId === threadId); + assert.equal(readySession?.status, "ready"); + void turnCompletedCount; + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects sendTurn with empty input and no attachments", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-empty-turn"); + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* startMockSession(adapter, threadId); + + const error = yield* Effect.flip( + adapter.sendTurn({ threadId, input: " ", attachments: [] }), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the pi child process when a session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("pi-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockPiWrapper({ + T3_PI_EXIT_LOG_PATH: exitLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* startMockSession(adapter, threadId); + yield* adapter.stopSession(threadId); + + const exitLog = yield* waitForFileContent(exitLogPath); + assert.include(exitLog, "SIGTERM"); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("pi-provider-mismatch"); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("rejects startSession when cwd is missing", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockPiWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("pi-cwd-missing"); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("piAgent"), + cwd: " ", + runtimeMode: "full-access", + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/PiAgentAdapter.ts b/apps/server/src/provider/Layers/PiAgentAdapter.ts new file mode 100644 index 00000000000..6b03c6b7da5 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAgentAdapter.ts @@ -0,0 +1,1389 @@ +/** + * PiAgentAdapter — per-instance Pi adapter. + * + * Maps the pi JSONL RPC protocol (via {@link PiAgentSessionRuntime}) onto the + * canonical `ProviderRuntimeEvent` stream. + * + * Design decisions (pi has no turn ids and no permission system): + * + * - **Turn correlation.** Pi's `prompt` RPC accepts an optional correlation + * `id`; we mint one per sendTurn and await the matching `response` record, + * so `sendTurn` returns once pi acknowledges the prompt. The turn's + * terminal event comes from `agent_settled` (pi fires it only when no + * retry/queue work remains), mapped to `turn.completed` with state + * `"completed"` — or `"interrupted"` when the turn was aborted first. + * A sendTurn issued while a run is still in flight is a *steer* + * (`streamingBehavior: "steer"`); it reuses the active turn id, mirroring + * the Grok adapter, because pi folds it into the ongoing run and emits a + * single `agent_settled`. + * - **Tool calls.** `tool_execution_start/update/end` map to + * `item.started/updated/completed` with a best-effort canonical type + * (bash → command_execution, write/edit → file_change, everything else → + * unknown — pi's tool names are unverified). + * - **Extension UI.** `confirm`/`select` become `user-input.requested` and + * answers are written back as `extension_ui_response`. `input`/`editor` + * requests are auto-cancelled with a `runtime.warning` because T3 Code has + * no free-text user input path. `notify` surfaces as a `runtime.warning` + * row. `setStatus`/`setWidget`/`setTitle`/`set_editor_text` are ignored + * (they only affect pi's own extension chrome, which T3 never renders). + * - **Token usage.** On `agent_settled` we try `get_session_stats` and emit + * `thread.token-usage.updated` when it yields usable numbers. + * - **rollback.** Pi has `fork`/`/tree` branching but no turn rollback, so + * `rollbackThread` fails with `ProviderAdapterValidationError`; + * checkpoint revert still restores the workspace. + * + * @module provider/Layers/PiAgentAdapter + */ +import { + ApprovalRequestId, + EventId, + type PiAgentSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderEvent, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Result from "effect/Result"; +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 { ChildProcessSpawner } from "effect/unstable/process"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type PiAgentAdapterShape } from "../Services/PiAgentAdapter.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + makePiAgentSessionRuntime, + parsePiResumeCursor, + type PiAvailableModel, + type PiSessionRuntimeShape, +} from "./PiAgentSessionRuntime.ts"; + +const PROVIDER = ProviderDriverKind.make("piAgent"); + +export const PI_THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; + +export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel { + return typeof value === "string" && (PI_THINKING_LEVELS as readonly string[]).includes(value); +} + +export interface PiAgentAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly value?: unknown; readonly confirmed?: boolean } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly extensionRequestId: string; + readonly turnId: TurnId | undefined; + readonly resolution: Deferred.Deferred; +} + +interface PiSessionContext { + readonly threadId: ThreadId; + readonly scope: Scope.Closeable; + readonly runtime: PiSessionRuntimeShape; + eventFiber: Fiber.Fiber | undefined; + session: ProviderSession; + activeTurnId: TurnId | undefined; + promptsInFlight: number; + readonly interruptedTurnIds: Set; + readonly pendingUserInputs: Map; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimText(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Resolve a T3 model slug (`provider/modelId` or bare `modelId`) against the + * pi catalog so `set_model` receives a concrete provider + model id. Bare ids + * fall back to `anthropic` when the catalog has no match. */ +export function resolvePiModelSelection( + slug: string, + availableModels: ReadonlyArray, +): { readonly provider: string; readonly modelId: string } { + const trimmed = slug.trim(); + const slash = trimmed.indexOf("/"); + if (slash > 0 && slash < trimmed.length - 1) { + return { provider: trimmed.slice(0, slash), modelId: trimmed.slice(slash + 1) }; + } + const match = availableModels.find((model) => model.id === trimmed); + return { + provider: match?.provider ?? match?.api ?? "anthropic", + modelId: trimmed, + }; +} + +function toCanonicalItemType( + toolName: string | undefined | null, +): Extract["payload"]["itemType"] { + const name = toolName?.toLowerCase().trim() ?? ""; + if (/^(bash|sh|shell|exec|run|command)/.test(name)) return "command_execution"; + if (/^(write|edit|patch|apply)/.test(name)) return "file_change"; + if (/^mcp/.test(name)) return "mcp_tool_call"; + if (/^(web|search)/.test(name)) return "web_search"; + return "unknown"; +} + +function itemTitleForTool( + itemType: ReturnType, + toolName: string | undefined, +): string | undefined { + if (itemType === "command_execution") return "Ran command"; + if (itemType === "file_change") return "File change"; + if (itemType === "mcp_tool_call") return "MCP tool call"; + if (itemType === "web_search") return "Web search"; + return trimText(toolName) ? `Tool: ${toolName}` : undefined; +} + +/** Map a pi `message_update.assistantMessageEvent` payload onto runtime events. */ +function mapAssistantMessageEvent( + event: ProviderEvent, + turnId: TurnId | undefined, + raw: Record, +): ReadonlyArray { + const assistantEvent = isRecord(raw.assistantMessageEvent) ? raw.assistantMessageEvent : raw; + const kind = + typeof assistantEvent.type === "string" + ? assistantEvent.type + : typeof assistantEvent.kind === "string" + ? assistantEvent.kind + : undefined; + const base = { + eventId: event.id, + provider: event.provider, + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), + threadId: event.threadId, + createdAt: event.createdAt, + ...(turnId ? { turnId } : {}), + }; + + switch (kind) { + case "text_delta": { + const delta = typeof assistantEvent.delta === "string" ? assistantEvent.delta : ""; + if (!delta) return []; + return [ + { + ...base, + type: "content.delta" as const, + payload: { + streamKind: "assistant_text" as const, + delta, + ...(typeof assistantEvent.contentIndex === "number" + ? { contentIndex: assistantEvent.contentIndex } + : {}), + }, + }, + ]; + } + case "thinking_delta": { + const delta = typeof assistantEvent.delta === "string" ? assistantEvent.delta : ""; + if (!delta) return []; + return [ + { + ...base, + type: "content.delta" as const, + payload: { streamKind: "reasoning_text" as const, delta }, + }, + ]; + } + case "toolcall_start": + return [ + { + ...base, + type: "item.started" as const, + payload: { itemType: "unknown" as const, status: "inProgress" as const }, + }, + ]; + case "toolcall_delta": + return [ + { + ...base, + type: "item.updated" as const, + payload: { itemType: "unknown" as const, data: assistantEvent }, + }, + ]; + case "toolcall_end": { + const toolCall = isRecord(assistantEvent.toolCall) ? assistantEvent.toolCall : undefined; + const toolName = typeof toolCall?.name === "string" ? toolCall.name : undefined; + const itemType = toCanonicalItemType(toolName); + return [ + { + ...base, + type: "item.completed" as const, + ...(toolCall?.id !== undefined + ? { itemId: RuntimeItemId.make(String(toolCall.id)) } + : {}), + payload: { + itemType, + status: "completed" as const, + ...(itemTitleForTool(itemType, toolName) + ? { title: itemTitleForTool(itemType, toolName) } + : {}), + }, + }, + ]; + } + default: + return []; + } +} + +export const makePiAgentAdapter = Effect.fn("makePiAgentAdapter")(function* ( + piSettings: PiAgentSettings, + options?: PiAgentAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("piAgent"); + 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; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventQueue = yield* Queue.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 Pi runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing) { + return Effect.succeed([existing, current] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const writeNativeEvent = Effect.fnUntraced(function* (event: ProviderEvent) { + if (!nativeEventLogger) { + return; + } + yield* nativeEventLogger.write(event, event.threadId); + }); + + const requireSession = Effect.fn("requireSession")(function* (threadId: ThreadId) { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + return ctx; + }); + + const settlePendingUserInputsAsCancelled = (ctx: PiSessionContext) => + Effect.forEach( + Array.from(ctx.pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); + + const resetSessionToReady = (ctx: PiSessionContext) => + Effect.gen(function* () { + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + ctx.session = { + ...ctx.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + }); + + const emitTurnCompleted = ( + ctx: PiSessionContext, + state: "completed" | "failed" | "interrupted" | "cancelled", + options?: { readonly errorMessage?: string; readonly usage?: unknown }, + ) => + Effect.gen(function* () { + const turnId = ctx.activeTurnId; + if (!turnId) { + return; + } + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + payload: { + state, + ...(options?.errorMessage ? { errorMessage: options.errorMessage } : {}), + ...(options?.usage !== undefined ? { usage: options.usage } : {}), + }, + }); + yield* resetSessionToReady(ctx); + }); + + const emitTokenUsage = (ctx: PiSessionContext, usage: unknown) => + Effect.gen(function* () { + if (!isRecord(usage)) { + return; + } + // pi's stats shapes are unverified; only emit when we can derive a + // positive token count. Accept either flat numbers or the + // get_session_stats { tokens, cost, contextUsage } envelope. + const tokens = isRecord(usage.tokens) ? usage.tokens : undefined; + const contextUsage = isRecord(usage.contextUsage) ? usage.contextUsage : undefined; + const inputTokens = + asNumber(usage.inputTokens) ?? asNumber(tokens?.input) ?? asNumber(tokens?.inputTokens); + const outputTokens = + asNumber(usage.outputTokens) ?? asNumber(tokens?.output) ?? asNumber(tokens?.outputTokens); + const usedTokens = + asNumber(contextUsage?.usedTokens) ?? + asNumber(usage.usedTokens) ?? + (inputTokens !== undefined && outputTokens !== undefined + ? inputTokens + outputTokens + : undefined); + if (usedTokens === undefined || usedTokens <= 0) { + return; + } + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + usage: { + usedTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + }, + }, + }); + }); + + const settleActiveTurn = (ctx: PiSessionContext) => + Effect.gen(function* () { + const turnId = ctx.activeTurnId; + if (!turnId) { + return; + } + const interrupted = ctx.interruptedTurnIds.has(turnId); + ctx.interruptedTurnIds.delete(turnId); + // Best-effort usage from pi's session stats; never fail the turn on it. + const stats = yield* ctx.runtime.getSessionStats().pipe(Effect.option); + if (Option.isSome(stats)) { + yield* emitTokenUsage(ctx, stats.value); + } + yield* emitTurnCompleted(ctx, interrupted ? "interrupted" : "completed"); + }); + + const toUserInputQuestions = ( + request: Record, + ): ReadonlyArray => { + const method = typeof request.method === "string" ? request.method : "select"; + const requestId = typeof request.id === "string" ? request.id : "unknown"; + const header = + trimText(typeof request.title === "string" ? request.title : undefined) ?? "Pi request"; + const message = trimText(typeof request.message === "string" ? request.message : undefined); + const question = message ?? header; + + if (method === "confirm") { + return [ + { + id: `${requestId}:confirm`, + header, + question, + options: [ + { label: "Allow", description: "Allow the requested action" }, + { label: "Deny", description: "Deny the requested action" }, + ], + }, + ]; + } + + const rawOptions = Array.isArray(request.options) ? request.options : []; + const options = rawOptions + .map((entry): UserInputQuestion["options"][number] | undefined => { + if (!isRecord(entry)) return undefined; + const value = typeof entry.value === "string" ? entry.value : undefined; + const label = trimText(typeof entry.label === "string" ? entry.label : value); + if (!label) return undefined; + return { label, description: value && value !== label ? value : label }; + }) + .filter((entry): entry is UserInputQuestion["options"][number] => entry !== undefined); + + if (options.length === 0) { + return []; + } + return [{ id: `${requestId}:select`, header, question, options }]; + }; + + const handleExtensionUiRequest = (ctx: PiSessionContext, event: ProviderEvent) => + Effect.gen(function* () { + const raw = isRecord(event.payload) ? event.payload : {}; + const method = typeof raw.method === "string" ? raw.method : undefined; + if (!method) { + return; + } + const extensionRequestId = typeof raw.id === "string" ? raw.id : ""; + const requestId = ApprovalRequestId.make(event.requestId ?? (yield* randomUUIDv4)); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const turnId = ctx.activeTurnId; + const rawPayload = raw; + switch (method) { + case "confirm": + case "select": { + const questions = toUserInputQuestions(raw); + if (questions.length === 0) { + // Nothing we can present; cancel the request so pi does not block. + yield* ctx.runtime + .respondToExtensionUi({ requestId: extensionRequestId, cancelled: true }) + .pipe(Effect.ignore); + return; + } + const resolution = yield* Deferred.make(); + ctx.pendingUserInputs.set(requestId, { + extensionRequestId, + turnId, + resolution, + }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions }, + raw: { source: "acp.pi.extension", method, payload: rawPayload }, + }); + const resolved = yield* Deferred.await(resolution); + ctx.pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { + answers: + resolved._tag === "answered" + ? { [questions[0]?.id ?? "answer"]: resolved.value ?? resolved.confirmed ?? "" } + : {}, + }, + raw: { source: "acp.pi.extension", method, payload: rawPayload }, + }); + if (resolved._tag === "answered") { + yield* ctx.runtime.respondToExtensionUi({ + requestId: extensionRequestId, + value: resolved.value, + ...(resolved.confirmed !== undefined ? { confirmed: resolved.confirmed } : {}), + }); + } else { + yield* ctx.runtime + .respondToExtensionUi({ requestId: extensionRequestId, cancelled: true }) + .pipe(Effect.ignore); + } + return; + } + case "input": + case "editor": { + yield* ctx.runtime + .respondToExtensionUi({ requestId: extensionRequestId, cancelled: true }) + .pipe(Effect.ignore); + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + payload: { + message: + "Pi requested free-text input, which T3 Code does not support; request cancelled.", + }, + raw: { source: "acp.pi.extension", method, payload: rawPayload }, + }); + return; + } + case "notify": { + const summary = + trimText(typeof raw.message === "string" ? raw.message : undefined) ?? + trimText(typeof raw.title === "string" ? raw.title : undefined) ?? + "Pi notification"; + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + turnId, + payload: { message: summary }, + raw: { source: "acp.pi.extension", method, payload: rawPayload }, + }); + return; + } + default: + // setStatus / setWidget / setTitle / set_editor_text only affect + // pi's own extension chrome, which T3 never renders. Ignore them. + yield* Effect.logDebug("ignoring pi extension UI method", { method }); + return; + } + }); + + const handlePiEvent = (ctx: PiSessionContext, event: ProviderEvent) => + Effect.gen(function* () { + yield* writeNativeEvent(event); + const activeTurnId = ctx.activeTurnId; + const base = { + eventId: event.id, + provider: event.provider, + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), + threadId: event.threadId, + createdAt: event.createdAt, + ...(activeTurnId ? { turnId: activeTurnId } : {}), + }; + const raw = isRecord(event.payload) ? event.payload : {}; + + switch (event.kind) { + case "session": + if (event.method === "session/connecting") { + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { state: "starting", reason: event.message ?? "Starting Pi session." }, + }); + return; + } + if (event.method === "session/ready") { + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { state: "ready", reason: "Pi session ready." }, + }); + return; + } + if (event.method === "session/exited") { + const message = event.message ?? "Pi session exited."; + const exitKind = message.includes("code") ? "error" : "graceful"; + // Clear run state so a later sendTurn cannot steer into the dead + // process's turn. + ctx.activeTurnId = undefined; + ctx.promptsInFlight = 0; + ctx.session = { + ...ctx.session, + status: exitKind === "error" ? "error" : "closed", + activeTurnId: undefined, + }; + if (activeTurnId && exitKind === "error") { + yield* offerRuntimeEvent({ + ...base, + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + } + yield* offerRuntimeEvent({ + ...base, + type: "session.exited", + payload: { reason: message, exitKind }, + }); + return; + } + return; + case "error": + yield* offerRuntimeEvent({ + ...base, + type: "runtime.error", + payload: { message: event.message ?? "Pi runtime error", class: "provider_error" }, + }); + return; + case "request": + if (event.method === "extension_ui_request") { + yield* handleExtensionUiRequest(ctx, event); + } + return; + case "notification": + break; + } + + switch (event.method) { + case "process/stderr": { + const message = event.message ?? "Pi process stderr"; + yield* offerRuntimeEvent({ + ...base, + type: "runtime.warning", + payload: { message }, + }); + return; + } + case "agent_start": { + // Ignore stale agent starts (e.g. arriving after a prompt failure + // already completed the turn and cleared the active turn id). + if (!ctx.activeTurnId) { + return; + } + ctx.session = { + ...ctx.session, + status: "running", + updatedAt: yield* nowIso, + }; + return; + } + case "message_update": { + for (const runtimeEvent of mapAssistantMessageEvent(event, activeTurnId, raw)) { + yield* offerRuntimeEvent(runtimeEvent); + } + return; + } + case "tool_execution_start": { + const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId : undefined; + const toolName = typeof raw.toolName === "string" ? raw.toolName : undefined; + const itemType = toCanonicalItemType(toolName); + yield* offerRuntimeEvent({ + ...base, + type: "item.started", + ...(toolCallId ? { itemId: RuntimeItemId.make(toolCallId) } : {}), + payload: { + itemType, + status: "inProgress", + ...(itemTitleForTool(itemType, toolName) + ? { title: itemTitleForTool(itemType, toolName) } + : {}), + ...(raw.args !== undefined ? { data: raw.args } : {}), + }, + }); + return; + } + case "tool_execution_update": { + const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId : undefined; + yield* offerRuntimeEvent({ + ...base, + type: "item.updated", + ...(toolCallId ? { itemId: RuntimeItemId.make(toolCallId) } : {}), + payload: { + itemType: "unknown", + data: raw.partialResult ?? raw, + }, + }); + return; + } + case "tool_execution_end": { + const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId : undefined; + const toolName = typeof raw.toolName === "string" ? raw.toolName : undefined; + const itemType = toCanonicalItemType(toolName); + yield* offerRuntimeEvent({ + ...base, + type: "item.completed", + ...(toolCallId ? { itemId: RuntimeItemId.make(toolCallId) } : {}), + payload: { + itemType, + status: raw.isError === true ? "failed" : "completed", + ...(itemTitleForTool(itemType, toolName) + ? { title: itemTitleForTool(itemType, toolName) } + : {}), + ...(raw.result !== undefined ? { data: raw.result } : {}), + }, + }); + return; + } + case "agent_settled": { + yield* settleActiveTurn(ctx); + return; + } + case "agent_end": { + if (raw.willRetry === true) { + yield* offerRuntimeEvent({ + ...base, + type: "runtime.warning", + payload: { message: "Pi will retry the last operation." }, + }); + } + return; + } + case "extension_error": { + const message = + typeof raw.message === "string" && raw.message.trim() + ? raw.message + : "Pi extension error"; + yield* offerRuntimeEvent({ + ...base, + type: "runtime.error", + payload: { message, class: "provider_error" }, + }); + if (activeTurnId) { + yield* emitTurnCompleted(ctx, "failed", { errorMessage: message }); + } + return; + } + case "compaction_start": + yield* offerRuntimeEvent({ + ...base, + type: "item.started", + payload: { + itemType: "context_compaction", + status: "inProgress", + title: "Compacting context", + }, + }); + return; + case "compaction_end": + yield* offerRuntimeEvent({ + ...base, + type: "item.completed", + payload: { + itemType: "context_compaction", + status: "completed", + title: "Compacting context", + }, + }); + return; + case "auto_retry_start": + yield* offerRuntimeEvent({ + ...base, + type: "runtime.warning", + payload: { message: "Pi is retrying automatically." }, + }); + return; + case "bash_execution_update": + case "queue_update": + case "turn_start": + case "turn_end": + // Informational; the session/turn lifecycle is driven by + // agent_start/agent_settled. + return; + default: + yield* Effect.logDebug("ignoring unhandled pi provider event", { + method: event.method, + threadId: event.threadId, + }); + return; + } + }); + + const stopSessionInternal = (ctx: PiSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) { + return; + } + ctx.stopped = true; + sessions.delete(ctx.threadId); + yield* settlePendingUserInputsAsCancelled(ctx); + if (ctx.eventFiber) { + yield* Fiber.interrupt(ctx.eventFiber).pipe(Effect.ignore); + } + yield* ctx.runtime.close.pipe(Effect.ignore); + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: ctx.threadId, + payload: { exitKind: "graceful", reason: "Session stopped" }, + }); + }); + + const startSession: PiAgentAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.scoped( + 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 existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parsePiResumeCursor(input.resumeCursor)?.sessionId; + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const thinkingLevel = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + + const runtime = yield* makePiAgentSessionRuntime({ + threadId: input.threadId, + providerInstanceId: boundInstanceId, + binaryPath: piSettings.binaryPath || "pi", + ...(piSettings.homePath ? { homePath: piSettings.homePath } : {}), + ...(piSettings.launchArgs ? { launchArgs: piSettings.launchArgs } : {}), + ...(options?.environment ? { environment: options.environment } : {}), + cwd, + runtimeMode: input.runtimeMode, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(isPiThinkingLevel(thinkingLevel) ? { thinkingLevel } : {}), + ...(resumeSessionId ? { resumeSessionId } : {}), + clientName: "t3-code", + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(Crypto.Crypto, crypto), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const sessionCreatedAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "connecting", + runtimeMode: input.runtimeMode, + cwd, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + threadId: input.threadId, + createdAt: sessionCreatedAt, + updatedAt: sessionCreatedAt, + }; + + const ctx: PiSessionContext = { + threadId: input.threadId, + scope: sessionScope, + runtime, + eventFiber: undefined, + session, + activeTurnId: undefined, + promptsInFlight: 0, + interruptedTurnIds: new Set(), + pendingUserInputs: new Map(), + stopped: false, + }; + + const eventFiber = yield* Stream.runForEach(runtime.events, (event) => + handlePiEvent(ctx, event).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Pi runtime notification.", { cause }), + ), + ), + ).pipe(Effect.forkChild); + ctx.eventFiber = eventFiber; + + const started = yield* runtime.start().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + ctx.session = started; + + // Apply the requested model + thinking level in-session. pi's + // `--model` is a pattern, so an explicit set_model makes the + // selection deterministic. Best-effort: a catalog RPC failure must + // not fail session start. + const availableModels = yield* ctx.runtime + .getAvailableModels() + .pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); + if (modelSelection?.model) { + const resolved = resolvePiModelSelection(modelSelection.model, availableModels); + yield* ctx.runtime + .setModel(resolved.provider, resolved.modelId) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to apply Pi model selection.", { cause }), + ), + ); + ctx.session = { ...ctx.session, model: modelSelection.model }; + } + if (isPiThinkingLevel(thinkingLevel)) { + yield* ctx.runtime + .setThinkingLevel(thinkingLevel) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to apply Pi thinking level.", { cause }), + ), + ); + } + ctx.session = { + ...ctx.session, + status: "ready", + updatedAt: yield* nowIso, + }; + + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { resume: resumeSessionId !== undefined }, + }); + yield* offerRuntimeEvent({ + type: "session.configured", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { + config: { + binaryPath: piSettings.binaryPath, + homePath: piSettings.homePath, + launchArgs: piSettings.launchArgs, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + }, + }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { state: "ready", reason: "Pi session ready" }, + }); + const providerThreadId = parsePiResumeCursor(ctx.session.resumeCursor)?.sessionId; + if (providerThreadId) { + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + payload: { providerThreadId }, + }); + } + + return ctx.session; + }), + ), + ); + + const resolveAttachment = Effect.fn("resolveAttachment")(function* ( + attachment: NonNullable[number], + ) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Failed to read attachment file: ${cause.message}.`, + cause, + }), + ), + ); + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }; + }); + + const sendTurn: PiAgentAdapterShape["sendTurn"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a run is still active (not yet settled) is a + // steer: pi folds it into the ongoing run and emits a single + // agent_settled, so the existing turn id is reused. + const steering = ctx.activeTurnId !== undefined; + const turnId = + steering && ctx.activeTurnId ? ctx.activeTurnId : TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const images = yield* Effect.forEach(input.attachments ?? [], resolveAttachment, { + concurrency: 1, + }); + const text = input.input?.trim(); + if (!text && images.length === 0) { + yield* resetSessionToReady(ctx); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // In-session model / thinking switches (pi has no turn-level model + // selection; set_model applies to the whole session). + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + if (modelSelection?.model) { + const availableModels = yield* ctx.runtime + .getAvailableModels() + .pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray))); + const resolved = resolvePiModelSelection(modelSelection.model, availableModels); + yield* ctx.runtime + .setModel(resolved.provider, resolved.modelId) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to apply Pi model selection on turn.", { cause }), + ), + ); + ctx.session = { ...ctx.session, model: modelSelection.model }; + } + const thinkingLevel = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + if (isPiThinkingLevel(thinkingLevel)) { + yield* ctx.runtime + .setThinkingLevel(thinkingLevel) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to apply Pi thinking level on turn.", { cause }), + ), + ); + } + + if (!steering) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + ...(ctx.session.providerInstanceId + ? { providerInstanceId: ctx.session.providerInstanceId } + : {}), + threadId: input.threadId, + turnId, + payload: { + ...(ctx.session.model ? { model: ctx.session.model } : {}), + ...(isPiThinkingLevel(thinkingLevel) ? { effort: thinkingLevel } : {}), + }, + }); + } + + const promptResult = yield* ctx.runtime + .sendPrompt({ + ...(text ? { message: text } : {}), + ...(images.length > 0 ? { images } : {}), + streamingBehavior: steering ? "steer" : "followUp", + }) + .pipe(Effect.result); + + if (Result.isFailure(promptResult)) { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + const errorMessage = promptResult.failure.message; + if (!ctx.interruptedTurnIds.has(turnId)) { + yield* emitTurnCompleted(ctx, "failed", { errorMessage }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: errorMessage, + cause: promptResult.failure, + }); + } + + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + }), + ); + + const interruptTurn: PiAgentAdapterShape["interruptTurn"] = (threadId, turnId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + const target = turnId ?? activeTurnId; + if (!target) { + return; + } + ctx.interruptedTurnIds.add(target); + // Pi's `abort` stops the current operation; the subsequent + // `agent_settled` completes the turn as interrupted. + yield* ctx.runtime.abort().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/interrupt", + detail: cause.message, + cause, + }), + ), + ); + }), + ); + + const respondToRequest: PiAgentAdapterShape["respondToRequest"] = () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: "Pi has no built-in permission system; there are no approval requests to answer.", + }), + ); + + const respondToUserInput: PiAgentAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers: ProviderUserInputAnswers, + ) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "extension_ui_response", + detail: `Unknown Pi user input request '${requestId}'.`, + }); + } + const firstEntry = Object.entries(answers)[0]; + const rawValue = firstEntry?.[1]; + const stringValue = Array.isArray(rawValue) + ? rawValue.map((entry) => String(entry))[0] + : typeof rawValue === "string" + ? rawValue + : undefined; + const confirmed = + stringValue === "Allow" || stringValue === "Yes" + ? true + : stringValue === "Deny" || stringValue === "No" + ? false + : undefined; + yield* Deferred.succeed(pending.resolution, { + _tag: "answered", + value: stringValue ?? rawValue, + ...(confirmed !== undefined ? { confirmed } : {}), + }).pipe(Effect.ignore); + }), + ); + + const readThread: PiAgentAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const messages = yield* ctx.runtime.readMessages().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "get_messages", + detail: cause.message, + cause, + }), + ), + ); + // Group the flat AgentMessage list into turns: each assistant message + // opens a turn, and the user messages before it attach to that turn. + const turns: Array<{ id: TurnId; items: Array }> = []; + let pendingUserItems: Array = []; + for (const message of messages) { + const record = isRecord(message) ? message : {}; + if (record.role === "assistant") { + const id = + typeof record.id === "string" && record.id ? record.id : `pi-msg-${turns.length + 1}`; + turns.push({ id: TurnId.make(id), items: [...pendingUserItems, message] }); + pendingUserItems = []; + } else { + pendingUserItems.push(message); + } + } + return { threadId, turns }; + }); + + const rollbackThread: PiAgentAdapterShape["rollbackThread"] = () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: + "Pi has no turn rollback (it offers fork/branching instead); checkpoint revert restores the workspace.", + }), + ); + + const stopSession: PiAgentAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = sessions.get(threadId); + if (!ctx) { + return; + } + yield* stopSessionInternal(ctx); + }); + + const listSessions: PiAgentAdapterShape["listSessions"] = () => + Effect.forEach( + Array.from(sessions.values()).filter((ctx) => !ctx.stopped), + (ctx) => Effect.succeed(ctx.session), + { concurrency: 1 }, + ); + + const hasSession: PiAgentAdapterShape["hasSession"] = (threadId) => + Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped)); + + const stopAll: PiAgentAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { + concurrency: 1, + discard: true, + }).pipe(Effect.asVoid); + + yield* Effect.acquireRelease(Effect.void, () => + stopAll().pipe( + Effect.andThen(Queue.shutdown(runtimeEventQueue)), + Effect.andThen(nativeEventLogger?.close() ?? Effect.void), + Effect.ignore, + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, + } satisfies PiAgentAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/PiAgentProvider.test.ts b/apps/server/src/provider/Layers/PiAgentProvider.test.ts new file mode 100644 index 00000000000..6da5cdd8046 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAgentProvider.test.ts @@ -0,0 +1,234 @@ +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { PiAgentSettings } from "@t3tools/contracts"; + +import { + buildInitialPiAgentProviderSnapshot, + checkPiAgentProviderStatus, + piDiscoveredModelsFromAvailableModels, +} from "./PiAgentProvider.ts"; + +const decodePiAgentSettings = Schema.decodeSync(PiAgentSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/pi-mock-agent.ts"); + +/** Writes a fake `pi` CLI that answers --version itself and defers everything + * else to the pi mock agent (which speaks the JSONL RPC protocol). */ +const writeMockPiWrapper = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-rpc-models-" }); + const wrapperPath = path.join(dir, "pi"); + const script = `#!/bin/sh +if [ "$1" = "--version" ]; then + printf "pi-cli 0.9.7\\n" + exit 0 +fi +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + yield* fs.writeFileString(wrapperPath, script); + yield* fs.chmod(wrapperPath, 0o755); + return wrapperPath; +}); + +describe("buildInitialPiAgentProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialPiAgentProviderSnapshot( + decodePiAgentSettings({ 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 a pending snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialPiAgentProviderSnapshot(decodePiAgentSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Pi"); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "openai/gpt-5", + ]); + }), + ); +}); + +describe("piDiscoveredModelsFromAvailableModels", () => { + it("maps RPC entries to provider/id slugs and dedupes", () => { + const models = piDiscoveredModelsFromAvailableModels([ + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + api: "anthropic", + }, + { id: "claude-sonnet-4-6", name: undefined, provider: "anthropic", api: undefined }, + { id: "gpt-5", name: "GPT-5", provider: "openai", api: "openai" }, + { id: "local-model", name: undefined, provider: undefined, api: undefined }, + ]); + expect(models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "openai/gpt-5", + "local-model", + ]); + expect(models[0]?.isCustom).toBe(false); + expect(models[0]?.name).toBe("Claude Sonnet 4.6"); + expect(models[2]?.name).toBe("local-model"); + }); + + it("filters out empty ids", () => { + const models = piDiscoveredModelsFromAvailableModels([ + { id: " ", name: undefined, provider: "anthropic", api: undefined }, + { id: "gpt-5", name: undefined, provider: "openai", api: undefined }, + ]); + expect(models.map((model) => model.slug)).toEqual(["openai/gpt-5"]); + }); +}); + +it.layer(NodeServices.layer)("checkPiAgentProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkPiAgentProviderStatus( + decodePiAgentSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/pi-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("falls back to the static catalog when the RPC model probe fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-success-" }); + const piPath = path.join(dir, "pi"); + yield* fs.writeFileString( + piPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "pi-cli 0.9.7\\n"', + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + ); + yield* fs.chmod(piPath, 0o755); + + return yield* checkPiAgentProviderStatus( + decodePiAgentSettings({ enabled: true, binaryPath: piPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("0.9.7"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "openai/gpt-5", + ]); + }), + ); + + it.effect("uses discovered models from the RPC get_available_models probe", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeMockPiWrapper; + return yield* checkPiAgentProviderStatus( + decodePiAgentSettings({ enabled: true, binaryPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.9.7"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "openai/gpt-5", + ]); + expect(snapshot.models[0]?.name).toBe("Claude Sonnet 4.6"); + expect(snapshot.models.every((model) => !model.isCustom)).toBe(true); + }), + ); + + it.effect("appends custom models on top of the discovered catalog", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* writeMockPiWrapper; + return yield* checkPiAgentProviderStatus( + decodePiAgentSettings({ + enabled: true, + binaryPath, + customModels: ["my-custom-model"], + }), + ); + }), + ); + + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5", + "openai/gpt-5", + "my-custom-model", + ]); + expect(snapshot.models[3]?.isCustom).toBe(true); + }), + ); + + 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 path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-pi-version-" }); + const piPath = path.join(dir, "pi"); + yield* fs.writeFileString( + piPath, + ["#!/bin/sh", 'printf "%s\\n" "broken pi install" >&2', "exit 2", ""].join("\n"), + ); + yield* fs.chmod(piPath, 0o755); + + return yield* checkPiAgentProviderStatus( + decodePiAgentSettings({ enabled: true, binaryPath: piPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Pi CLI is installed but failed to run."); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/PiAgentProvider.ts b/apps/server/src/provider/Layers/PiAgentProvider.ts new file mode 100644 index 00000000000..09f3f97dc25 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAgentProvider.ts @@ -0,0 +1,332 @@ +import { + type ModelCapabilities, + type PiAgentSettings, + type ServerProviderModel, + ThreadId, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { makePiAgentSessionRuntime, type PiAvailableModel } from "./PiAgentSessionRuntime.ts"; + +const PI_PRESENTATION = { + displayName: "Pi", + showInteractionModeToggle: false, + // Pi switches models in-session via set_model, so a new thread is not + // required for a model change. + requiresNewThreadForModelChange: false, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_PROBE_TIMEOUT_MS = 10_000; + +/** + * Static catalog used when the RPC model probe (`get_available_models`) + * cannot be completed. Custom models from settings are always appended on + * top. + */ +const PI_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: piModelCapabilities(true), + }, + { + slug: "anthropic/claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: piModelCapabilities(false), + }, + { + slug: "openai/gpt-5", + name: "GPT-5", + isCustom: false, + capabilities: piModelCapabilities(true), + }, +]; + +function piModelCapabilities(reasoning: boolean): ModelCapabilities { + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: [ + { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + ], + }), + ], + }); +} + +function piModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = PI_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], piModelCapabilities(true)); +} + +export function buildInitialPiAgentProviderSnapshot( + piSettings: PiAgentSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = piModelsFromSettings(piSettings.customModels); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi CLI availability...", + }, + }); + }); +} + +const runPiVersionCommand = ( + piSettings: PiAgentSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = piSettings.binaryPath || "pi"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +/** + * Probe pi's model catalog over the verified RPC surface: spawn + * `pi --mode rpc` (the same transport sessions use) and ask for + * `get_available_models`. The runtime owns the process; closing the scope + * kills it. Best-effort — the caller falls back to the static catalog on + * any failure, matching the adapter's posture that a catalog RPC failure + * must not fail session start. + */ +const probePiAvailableModels = ( + piSettings: PiAgentSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const runtime = yield* makePiAgentSessionRuntime({ + threadId: ThreadId.make(yield* crypto.randomUUIDv4), + binaryPath: piSettings.binaryPath || "pi", + ...(piSettings.homePath?.trim() ? { homePath: piSettings.homePath.trim() } : {}), + ...(piSettings.launchArgs?.trim() ? { launchArgs: piSettings.launchArgs } : {}), + environment, + cwd: process.cwd(), + runtimeMode: "full-access", + clientName: "t3-code-provider-probe", + }); + return yield* runtime.getAvailableModels(); + }).pipe(Effect.scoped); + +/** + * Map RPC catalog entries to provider models. T3 slugs are `provider/id` + * (pi resolves bare ids against its configured providers, so entries + * without a provider stay bare). Verified against pi 0.83.0. + */ +export function piDiscoveredModelsFromAvailableModels( + availableModels: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return availableModels + .map((model): ServerProviderModel | undefined => { + const id = model.id.trim(); + if (!id) { + return undefined; + } + const provider = model.provider?.trim(); + const slug = provider ? `${provider}/${id}` : id; + if (seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name?.trim() || slug, + isCustom: false, + capabilities: piModelCapabilities(true), + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +export const checkPiAgentProviderStatus = Effect.fn("checkPiAgentProviderStatus")(function* ( + piSettings: PiAgentSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = piModelsFromSettings(piSettings.customModels); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Pi CLI (`pi`) is not installed or not on PATH." + : "Failed to execute Pi CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but timed out while running `pi --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Pi CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Pi CLI is installed but failed to run.", + }, + }); + } + + // Model discovery over RPC is best-effort; a failed or slow probe falls + // back to the static catalog and never degrades the provider status. + const modelsResult = yield* probePiAvailableModels(piSettings, environment).pipe( + Effect.timeoutOption(MODEL_PROBE_TIMEOUT_MS), + Effect.result, + ); + let discoveredModels: ReadonlyArray = []; + if (Result.isFailure(modelsResult)) { + yield* Effect.logWarning("Pi RPC model probe failed; using static catalog.", { + errorTag: modelsResult.failure._tag, + }); + } else if (Option.isNone(modelsResult.success)) { + yield* Effect.logWarning( + `Pi RPC model probe timed out after ${MODEL_PROBE_TIMEOUT_MS}ms; using static catalog.`, + ); + } else { + discoveredModels = piDiscoveredModelsFromAvailableModels(modelsResult.success.value); + } + const models = + discoveredModels.length > 0 + ? piModelsFromSettings(piSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); diff --git a/apps/server/src/provider/Layers/PiAgentSessionRuntime.ts b/apps/server/src/provider/Layers/PiAgentSessionRuntime.ts new file mode 100644 index 00000000000..9eec6715fd7 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAgentSessionRuntime.ts @@ -0,0 +1,685 @@ +/** + * PiAgentSessionRuntime — one `pi --mode rpc` child process plus the pi + * JSONL-over-stdio protocol. + * + * The runtime owns the process and translates raw pi records into the + * provider-neutral `ProviderEvent` records the adapter consumes. RPC + * commands that carry a correlation `id` (we attach one to every command) + * are awaited by `Deferred` and resolved when pi answers with a matching + * `response` record. + * + * Framing: pi writes one JSON record per line. Records are split on `\n` + * only and a single trailing `\r` is stripped. Node's `readline` is NOT + * compliant here because it also splits on U+2028/U+2029, which are legal + * inside JSON string values, so we use a StringDecoder-based splitter + * (see `makePiRecordSplitter` below). + * + * @module provider/Layers/PiAgentSessionRuntime + */ +import * as NodeStringDecoder from "node:string_decoder"; + +import { + ApprovalRequestId, + EventId, + ProviderDriverKind, + type ProviderEvent, + type ProviderInstanceId, + type ProviderSession, + type RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +const PROVIDER = ProviderDriverKind.make("piAgent"); + +/** Versioned resume cursor shared with the adapter (`{schemaVersion: 1, sessionId}`). */ +export const PI_RESUME_SCHEMA_VERSION = 1 as const; + +/** How long to wait for a pi RPC response before failing the operation. */ +const PI_RPC_TIMEOUT = "30 seconds" as const; +/** Startup RPCs (get_state, set_model, …) can be slower on first launch. */ +const PI_START_RPC_TIMEOUT = "45 seconds" as const; + +export class PiSessionRuntimeError extends Schema.TaggedErrorClass()( + "PiSessionRuntimeError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Pi session runtime failed in ${this.operation}: ${this.detail}`; + } +} + +export interface PiAgentSessionRuntimeOptions { + readonly threadId: ThreadId; + readonly providerInstanceId?: ProviderInstanceId; + readonly binaryPath: string; + /** `PI_CODING_AGENT_DIR` override — pi's home/session dir. */ + readonly homePath?: string; + /** Extra CLI args appended to `pi --mode rpc …` (from settings.launchArgs). */ + readonly launchArgs?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd: string; + readonly runtimeMode: RuntimeMode; + /** Model pattern passed to `--model` at spawn (T3 slug or bare id). */ + readonly model?: string; + /** Thinking level passed to `--thinking` at spawn. */ + readonly thinkingLevel?: string; + /** Resume a previous pi session (from the versioned resume cursor). */ + readonly resumeSessionId?: string; + readonly clientName: string; +} + +export interface PiAvailableModel { + readonly id: string; + readonly name: string | undefined; + readonly provider: string | undefined; + readonly api: string | undefined; +} + +export interface PiSessionStats { + readonly tokens: unknown; + readonly cost: unknown; + readonly contextUsage: unknown; +} + +export interface PiSessionRuntimeShape { + /** Spawn pi, query session state, apply model/thinking, emit session/ready. */ + readonly start: () => Effect.Effect; + readonly getSession: Effect.Effect; + readonly sendPrompt: (input: { + readonly message?: string; + readonly images?: ReadonlyArray<{ + readonly type: "image"; + readonly data: string; + readonly mimeType: string; + }>; + readonly streamingBehavior: "steer" | "followUp"; + }) => Effect.Effect; + readonly abort: () => Effect.Effect; + readonly setModel: ( + provider: string, + modelId: string, + ) => Effect.Effect; + readonly setThinkingLevel: (level: string) => Effect.Effect; + readonly getAvailableModels: () => Effect.Effect< + ReadonlyArray, + PiSessionRuntimeError + >; + readonly getSessionStats: () => Effect.Effect; + readonly readMessages: () => Effect.Effect, PiSessionRuntimeError>; + readonly respondToExtensionUi: (input: { + readonly requestId: string; + readonly value?: unknown; + readonly confirmed?: boolean; + readonly cancelled?: boolean; + }) => Effect.Effect; + readonly events: Stream.Stream; + readonly close: Effect.Effect; +} + +interface PiRpcResponse { + readonly command: string; + readonly success: boolean; + readonly data: unknown; + readonly error: unknown; +} + +interface PendingRpc { + readonly command: string; + readonly deferred: Deferred.Deferred; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPiResponseRecord(value: unknown): value is Record & { + readonly type: "response"; + readonly command: string; +} { + return isRecord(value) && value.type === "response" && typeof value.command === "string"; +} + +/** + * Record framing for pi's JSONL protocol. Splits on `\n` only and strips a + * single trailing `\r`; `StringDecoder` handles UTF-8 multi-byte sequences + * that span chunk boundaries. + */ +export function makePiRecordSplitter(): { + readonly push: (chunk: Uint8Array) => ReadonlyArray; + readonly flush: () => ReadonlyArray; +} { + const decoder = new NodeStringDecoder.StringDecoder("utf8"); + let remainder = ""; + const flushRecords = (): ReadonlyArray => { + if (!remainder.includes("\n")) { + return []; + } + const lines = remainder.split("\n"); + remainder = lines.pop() ?? ""; + return lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); + }; + return { + push: (chunk) => { + remainder += decoder.write(chunk); + return flushRecords(); + }, + flush: () => { + remainder += decoder.end(); + return flushRecords(); + }, + }; +} + +export function parsePiResumeCursor(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== PI_RESUME_SCHEMA_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +export const makePiAgentSessionRuntime = Effect.fn("makePiAgentSessionRuntime")(function* ( + options: PiAgentSessionRuntimeOptions, +): Effect.fn.Return< + PiSessionRuntimeShape, + PiSessionRuntimeError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope +> { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const events = yield* Queue.unbounded(); + const pendingRpcRef = yield* Ref.make(new Map()); + const closedRef = yield* Ref.make(false); + const stdinMutex = yield* Semaphore.make(1); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = (purpose: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new PiSessionRuntimeError({ + operation: "randomUUID", + detail: `Failed to generate ${purpose}.`, + cause, + }), + ), + ); + + const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; + const env = { + ...options.environment, + ...(resolvedHomePath ? { PI_CODING_AGENT_DIR: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand( + options.binaryPath, + [ + "--mode", + "rpc", + "--name", + options.clientName, + ...(options.resumeSessionId ? ["--session", options.resumeSessionId] : []), + ...(options.model ? ["--model", options.model] : []), + ...(options.thinkingLevel ? ["--thinking", options.thinkingLevel] : []), + ...tokenizeCliArgs(options.launchArgs), + ], + { env }, + ); + + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: options.cwd, + env, + // endOnDone must stay false: each writeLine is a separate + // Stream.run over the stdin sink, and ending on done would close + // the pipe after the first command. + stdin: { stream: "pipe", endOnDone: false }, + stdout: "pipe", + stderr: "pipe", + forceKillAfter: "2 seconds", + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new PiSessionRuntimeError({ + operation: "spawn", + detail: `Failed to spawn '${options.binaryPath} --mode rpc': ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); + + const emitEvent = (event: Omit) => + Effect.gen(function* () { + const id = yield* randomUUIDv4("provider-event"); + return yield* offerEvent({ + id: EventId.make(id), + provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), + createdAt: yield* nowIso, + ...event, + }); + }); + + const emitSessionEvent = (method: string, message: string) => + emitEvent({ kind: "session", threadId: options.threadId, method, message }); + + /** Write one JSON record to the pi process stdin, serialized by a mutex. */ + const writeLine = (record: unknown) => + stdinMutex.withPermits(1)( + Effect.gen(function* () { + const encoded = `${JSON.stringify(record)}\n`; + yield* Stream.run(Stream.encodeText(Stream.make(encoded)), child.stdin).pipe( + Effect.mapError( + (cause) => + new PiSessionRuntimeError({ + operation: "write", + detail: `Failed to write to pi process: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + }), + ); + + const failAllPendingRpcs = (detail: string) => + Ref.get(pendingRpcRef).pipe( + Effect.flatMap((pending) => + Effect.forEach( + Array.from(pending.values()), + (entry) => + Deferred.fail( + entry.deferred, + new PiSessionRuntimeError({ operation: entry.command, detail }), + ).pipe(Effect.ignore), + { discard: true }, + ), + ), + Effect.andThen(Ref.set(pendingRpcRef, new Map())), + ); + + const sendCommand = Effect.fn("sendCommand")(function* ( + command: string, + payload: Record = {}, + options?: { readonly timeout?: Duration.Input }, + ) { + const id = yield* randomUUIDv4(`rpc-id-${command}`); + const deferred = yield* Deferred.make(); + yield* Ref.update(pendingRpcRef, (current) => { + const next = new Map(current); + next.set(id, { command, deferred }); + return next; + }); + yield* writeLine({ type: command, id, ...payload }); + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: options?.timeout ?? PI_RPC_TIMEOUT, + orElse: () => + Effect.fail( + new PiSessionRuntimeError({ + operation: command, + detail: `pi did not answer '${command}' within ${options?.timeout ?? PI_RPC_TIMEOUT}.`, + }), + ), + }), + Effect.ensuring( + Ref.update(pendingRpcRef, (current) => { + if (!current.has(id)) return current; + const next = new Map(current); + next.delete(id); + return next; + }), + ), + ); + }); + + const rpcSuccess = Effect.fn("rpcSuccess")(function* ( + command: string, + payload: Record = {}, + options?: { readonly timeout?: Duration.Input }, + ): Effect.fn.Return { + const response = yield* sendCommand(command, payload, options); + if (response.success) { + return response.data; + } + const errorDetail = + isRecord(response.error) && typeof response.error.message === "string" + ? response.error.message + : typeof response.error === "string" + ? response.error + : undefined; + return yield* new PiSessionRuntimeError({ + operation: command, + detail: errorDetail + ? `pi rejected '${command}': ${errorDetail}` + : `pi rejected '${command}'.`, + }); + }); + + const handleRawRecord = (raw: unknown) => + Effect.gen(function* () { + if (isPiResponseRecord(raw)) { + const id = typeof raw.id === "string" ? raw.id : undefined; + if (id === undefined) { + yield* Effect.logDebug("pi response without correlation id", { + command: raw.command, + }); + return; + } + const pending = (yield* Ref.get(pendingRpcRef)).get(id); + if (!pending) { + yield* Effect.logDebug("pi response for unknown correlation id", { + id, + command: raw.command, + }); + return; + } + yield* Ref.update(pendingRpcRef, (current) => { + if (!current.has(id)) return current; + const next = new Map(current); + next.delete(id); + return next; + }); + yield* Deferred.succeed(pending.deferred, { + command: raw.command, + success: raw.success === true, + data: raw.data, + error: raw.error, + }).pipe(Effect.ignore); + return; + } + + if (!isRecord(raw)) { + yield* Effect.logDebug("ignoring non-record pi output", { raw }); + return; + } + + if (raw.type === "extension_ui_request" && typeof raw.id === "string") { + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "extension_ui_request", + requestId: ApprovalRequestId.make(raw.id), + payload: raw, + }); + return; + } + + if (typeof raw.type === "string") { + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: raw.type, + payload: raw, + }); + return; + } + + yield* Effect.logDebug("ignoring unrecognized pi output", { raw }); + }); + + const recordSplitter = makePiRecordSplitter(); + yield* child.stdout.pipe( + Stream.runForEach((chunk) => + Effect.gen(function* () { + for (const line of recordSplitter.push(chunk)) { + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + yield* Effect.logWarning("pi stdout carried a non-JSON record", { + threadId: options.threadId, + }); + continue; + } + yield* handleRawRecord(parsed); + } + }), + ), + Effect.catch((cause) => + Effect.logWarning("pi stdout stream ended or failed.", { + threadId: options.threadId, + cause, + }), + ), + Effect.forkIn(runtimeScope), + ); + + // Stderr is a diagnostics channel; surface each non-empty line as a + // warning event so the adapter can render it without failing the turn. + const stderrRemainderRef = yield* Ref.make(""); + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.modify(stderrRemainderRef, (current) => { + const combined = current + chunk; + const lines = combined.split("\n"); + const remainder = lines.pop() ?? ""; + return [lines.map((line) => line.replace(/\r$/, "")), remainder] as const; + }).pipe( + Effect.flatMap((lines) => + Effect.forEach( + lines, + (line) => { + const trimmed = line.trim(); + if (!trimmed) { + return Effect.void; + } + return emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "process/stderr", + message: trimmed, + }); + }, + { discard: true }, + ), + ), + ), + ), + Effect.catch((cause) => + Effect.logWarning("pi stderr stream ended or failed.", { + threadId: options.threadId, + cause, + }), + ), + Effect.forkIn(runtimeScope), + ); + + const sessionCreatedAt = yield* nowIso; + const initialSession: ProviderSession = { + provider: PROVIDER, + ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), + status: "connecting", + runtimeMode: options.runtimeMode, + cwd: options.cwd, + ...(options.model ? { model: options.model } : {}), + threadId: options.threadId, + createdAt: sessionCreatedAt, + updatedAt: sessionCreatedAt, + }; + const sessionRef = yield* Ref.make(initialSession); + + yield* child.exitCode.pipe( + Effect.flatMap((exitCode) => + Ref.get(closedRef).pipe( + Effect.flatMap((closed) => { + if (closed) { + return Effect.void; + } + const message = + exitCode === 0 ? "Pi process exited." : `Pi process exited with code ${exitCode}.`; + return Ref.update(sessionRef, (session) => ({ + ...session, + status: exitCode === 0 ? ("closed" as const) : ("error" as const), + activeTurnId: undefined, + })).pipe( + Effect.andThen(failAllPendingRpcs(message)), + Effect.andThen( + emitSessionEvent("session/exited", message).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit pi session exited event.", { cause }), + ), + ), + ), + ); + }), + ), + ), + Effect.forkIn(runtimeScope), + ); + + const readCurrentState = Effect.fn("readCurrentState")(function* () { + const data = yield* rpcSuccess("get_state", {}, { timeout: PI_START_RPC_TIMEOUT }); + if (!isRecord(data)) { + return yield* new PiSessionRuntimeError({ + operation: "get_state", + detail: "pi get_state returned no data object.", + }); + } + const sessionId = typeof data.sessionId === "string" ? data.sessionId : undefined; + if (!sessionId) { + return yield* new PiSessionRuntimeError({ + operation: "get_state", + detail: "pi get_state returned no sessionId.", + }); + } + return { sessionId }; + }); + + const start = Effect.fn("PiSessionRuntime.start")(function* () { + yield* emitSessionEvent("session/connecting", "Starting Pi session."); + const state = yield* readCurrentState(); + + const resumeCursor = { + schemaVersion: PI_RESUME_SCHEMA_VERSION, + sessionId: state.sessionId, + }; + const updatedAt = yield* nowIso; + yield* Ref.update(sessionRef, (session) => ({ + ...session, + status: "ready" as const, + resumeCursor, + updatedAt, + })); + + yield* emitSessionEvent("session/ready", "Pi session ready."); + return yield* Ref.get(sessionRef); + }); + + const close = Effect.gen(function* () { + const alreadyClosed = yield* Ref.getAndSet(closedRef, true); + if (alreadyClosed) { + return; + } + yield* failAllPendingRpcs("Pi session closed."); + const updatedAt = yield* nowIso; + yield* Ref.update(sessionRef, (session) => ({ + ...session, + status: "closed" as const, + activeTurnId: undefined, + updatedAt, + })); + yield* emitSessionEvent("session/exited", "Session stopped").pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit pi session exited event.", { cause }), + ), + ); + yield* Scope.close(runtimeScope, Exit.void); + yield* Queue.shutdown(events); + }); + + return { + start, + getSession: Ref.get(sessionRef), + sendPrompt: (input) => + Effect.gen(function* () { + const payload: Record = { + message: input.message ?? "", + streamingBehavior: input.streamingBehavior, + }; + if (input.images && input.images.length > 0) { + payload.images = input.images; + } + yield* rpcSuccess("prompt", payload); + }), + abort: () => + Effect.gen(function* () { + yield* writeLine({ type: "abort" }); + }), + setModel: (provider, modelId) => + Effect.gen(function* () { + yield* rpcSuccess("set_model", { provider, modelId }); + }), + setThinkingLevel: (level) => + Effect.gen(function* () { + yield* rpcSuccess("set_thinking_level", { level }); + }), + getAvailableModels: () => + Effect.gen(function* () { + const data = yield* rpcSuccess("get_available_models"); + if (!isRecord(data) || !Array.isArray(data.models)) { + return [] as ReadonlyArray; + } + return data.models + .filter(isRecord) + .map((model) => ({ + id: typeof model.id === "string" ? model.id : "", + name: typeof model.name === "string" ? model.name : undefined, + provider: typeof model.provider === "string" ? model.provider : undefined, + api: typeof model.api === "string" ? model.api : undefined, + })) + .filter((model) => model.id.length > 0); + }), + getSessionStats: () => + Effect.gen(function* () { + const data = yield* rpcSuccess("get_session_stats"); + return isRecord(data) + ? { tokens: data.tokens, cost: data.cost, contextUsage: data.contextUsage } + : { tokens: undefined, cost: undefined, contextUsage: undefined }; + }), + readMessages: () => + Effect.gen(function* () { + const data = yield* rpcSuccess("get_messages"); + return isRecord(data) && Array.isArray(data.messages) ? data.messages : []; + }), + respondToExtensionUi: (input) => + Effect.gen(function* () { + // Pi treats extension_ui_response as fire-and-forget (no ack), and + // the payload's `id` is the extension request id, not a correlation + // id — so write it directly instead of awaiting an RPC response. + const payload: Record = { id: input.requestId }; + if (input.value !== undefined) payload.value = input.value; + if (input.confirmed !== undefined) payload.confirmed = input.confirmed; + if (input.cancelled !== undefined) payload.cancelled = input.cancelled; + yield* writeLine({ type: "extension_ui_response", ...payload }); + }), + events: Stream.fromQueue(events), + close, + } satisfies PiSessionRuntimeShape; +}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a..db3c067ed49 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as OpenClawRuntime from "../openclawRuntime.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1485,7 +1486,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.mergeAll( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenClawRuntime.OpenClawRuntimeLive, + ), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -1555,6 +1561,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + hermes: { enabled: false }, + openclaw: { enabled: false }, }, }), ), @@ -1578,7 +1586,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.mergeAll( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenClawRuntime.OpenClawRuntimeLive, + ), + ), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1614,7 +1627,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); + // The piAgent default instance is auto-bootstrapped and probed + // with its default binary path ("pi"), which is also missing. + assert.deepStrictEqual(spawnedCommands, [firstMissing, "pi"]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1651,7 +1666,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.deepStrictEqual(spawnedCommands, [firstMissing, "pi", secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1700,7 +1715,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.mergeAll( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenClawRuntime.OpenClawRuntimeLive, + ), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -1762,7 +1782,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.mergeAll( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenClawRuntime.OpenClawRuntimeLive, + ), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { @@ -1807,7 +1832,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "hermes", + "openclaw", "opencode", + "piAgent", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Services/HermesAdapter.ts b/apps/server/src/provider/Services/HermesAdapter.ts new file mode 100644 index 00000000000..eb5fdb55b36 --- /dev/null +++ b/apps/server/src/provider/Services/HermesAdapter.ts @@ -0,0 +1,16 @@ +/** + * HermesAdapter — shape type for the Hermes provider adapter. + * + * The driver model ({@link ../Drivers/HermesDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module HermesAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * HermesAdapterShape — per-instance Hermes adapter contract. + */ +export interface HermesAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/OpenClawAdapter.ts b/apps/server/src/provider/Services/OpenClawAdapter.ts new file mode 100644 index 00000000000..87157790199 --- /dev/null +++ b/apps/server/src/provider/Services/OpenClawAdapter.ts @@ -0,0 +1,16 @@ +/** + * OpenClawAdapter — shape type for the OpenClaw provider adapter. + * + * The driver model ({@link ../Drivers/OpenClawDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module OpenClawAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * OpenClawAdapterShape — per-instance OpenClaw adapter contract. + */ +export interface OpenClawAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/PiAgentAdapter.ts b/apps/server/src/provider/Services/PiAgentAdapter.ts new file mode 100644 index 00000000000..da3c2c3f81a --- /dev/null +++ b/apps/server/src/provider/Services/PiAgentAdapter.ts @@ -0,0 +1,16 @@ +/** + * PiAgentAdapter — shape type for the Pi provider adapter. + * + * The driver model ({@link ../Drivers/PiAgentDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module PiAgentAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * PiAgentAdapterShape — per-instance Pi adapter contract. + */ +export interface PiAgentAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpReasoningConfig.test.ts b/apps/server/src/provider/acp/AcpReasoningConfig.test.ts new file mode 100644 index 00000000000..4f203f7cfb0 --- /dev/null +++ b/apps/server/src/provider/acp/AcpReasoningConfig.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + acpReasoningCapabilities, + applyAcpReasoningConfig, + buildAcpReasoningOptionDescriptor, + findAcpReasoningConfigOption, + resolveAcpReasoningConfigUpdate, +} from "./AcpReasoningConfig.ts"; + +const reasoningSelectOption = { + id: "reasoning", + name: "Reasoning Effort", + category: "model_config", + type: "select" as const, + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], +} satisfies EffectAcpSchema.SessionConfigOption; + +const effortByIdOption = { + id: "effort", + name: "Reasoning", + category: "model_option", + type: "select" as const, + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + ], +} satisfies EffectAcpSchema.SessionConfigOption; + +/** Parameterized (grouped) select options, as the ACP schema allows. */ +const parameterizedReasoningOption = { + id: "thought_level", + name: "Reasoning", + category: "model_config", + type: "select" as const, + currentValue: "high", + options: [ + { + group: "levels", + name: "Levels", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + { value: "high", name: "High (duplicate)" }, + ], + }, + ], +} satisfies EffectAcpSchema.SessionConfigOption; + +const unrelatedOption = { + id: "approval", + name: "Approval Mode", + category: "permission", + type: "select" as const, + currentValue: "ask", + options: [{ value: "ask", name: "Ask" }], +} satisfies EffectAcpSchema.SessionConfigOption; + +describe("findAcpReasoningConfigOption", () => { + it("matches a select option whose name mentions reasoning", () => { + expect(findAcpReasoningConfigOption([unrelatedOption, reasoningSelectOption])).toBe( + reasoningSelectOption, + ); + }); + + it("matches a select option whose id is effort", () => { + expect(findAcpReasoningConfigOption([unrelatedOption, effortByIdOption])).toBe( + effortByIdOption, + ); + }); + + it("ignores non-select and unrelated options", () => { + expect(findAcpReasoningConfigOption([unrelatedOption])).toBeUndefined(); + expect(findAcpReasoningConfigOption([])).toBeUndefined(); + expect(findAcpReasoningConfigOption(undefined)).toBeUndefined(); + }); +}); + +describe("buildAcpReasoningOptionDescriptor", () => { + it("builds a reasoning descriptor from the CLI's native values and preselects its current value", () => { + const descriptor = buildAcpReasoningOptionDescriptor([reasoningSelectOption]); + expect(descriptor).toEqual({ + id: "reasoning", + label: "Reasoning Effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }); + }); + + it("flattens parameterized select groups and de-duplicates by value", () => { + const descriptor = buildAcpReasoningOptionDescriptor([parameterizedReasoningOption]); + expect(descriptor?.options.map((option) => option.id)).toEqual(["low", "high"]); + expect(descriptor?.options.find((option) => option.id === "high")?.isDefault).toBe(true); + expect(descriptor?.currentValue).toBe("high"); + }); + + it("falls back to the descriptor label and the value as the option label when the name is empty", () => { + const option = { + id: "effort", + name: "", + category: "model_option", + type: "select" as const, + currentValue: "v1", + options: [{ value: "v1", name: "" }], + } satisfies EffectAcpSchema.SessionConfigOption; + const descriptor = buildAcpReasoningOptionDescriptor([option]); + expect(descriptor?.label).toBe("Reasoning"); + expect(descriptor?.options).toEqual([{ id: "v1", label: "v1", isDefault: true }]); + }); + + it("returns undefined when no effort-shaped option is declared", () => { + expect(buildAcpReasoningOptionDescriptor([unrelatedOption])).toBeUndefined(); + expect(buildAcpReasoningOptionDescriptor([])).toBeUndefined(); + expect(buildAcpReasoningOptionDescriptor(undefined)).toBeUndefined(); + }); +}); + +describe("acpReasoningCapabilities", () => { + it("carries the reasoning descriptor when one is declared", () => { + const caps = acpReasoningCapabilities([reasoningSelectOption]); + expect(caps.optionDescriptors?.length).toBe(1); + expect(caps.optionDescriptors?.[0]?.id).toBe("reasoning"); + }); + + it("is empty when no reasoning option is declared", () => { + expect(acpReasoningCapabilities([unrelatedOption]).optionDescriptors).toEqual([]); + expect(acpReasoningCapabilities(undefined).optionDescriptors).toEqual([]); + }); +}); + +describe("resolveAcpReasoningConfigUpdate", () => { + it("maps a reasoning selection back to the originating config option", () => { + expect( + resolveAcpReasoningConfigUpdate([reasoningSelectOption], [{ id: "reasoning", value: "low" }]), + ).toEqual({ configId: "reasoning", value: "low" }); + }); + + it("matches the selection against the option name as a fallback", () => { + expect( + resolveAcpReasoningConfigUpdate([effortByIdOption], [{ id: "reasoning", value: "Medium" }]), + ).toEqual({ configId: "effort", value: "medium" }); + }); + + it("returns undefined when the selection is absent or unknown", () => { + expect(resolveAcpReasoningConfigUpdate([reasoningSelectOption], undefined)).toBeUndefined(); + expect( + resolveAcpReasoningConfigUpdate( + [reasoningSelectOption], + [{ id: "reasoning", value: "ultra" }], + ), + ).toBeUndefined(); + }); + + it("returns undefined when no effort option is declared", () => { + expect( + resolveAcpReasoningConfigUpdate([unrelatedOption], [{ id: "reasoning", value: "low" }]), + ).toBeUndefined(); + }); +}); + +function makeStubRuntime( + configOptions: ReadonlyArray, + setConfigOptionImpl: ( + configId: string, + value: string | boolean, + ) => Effect.Effect, +) { + const calls: Array<{ readonly configId: string; readonly value: string | boolean }> = []; + return { + runtime: { + getConfigOptions: Effect.succeed(configOptions), + setConfigOption: (configId: string, value: string | boolean) => { + calls.push({ configId, value }); + return setConfigOptionImpl(configId, value); + }, + }, + calls, + }; +} + +describe("applyAcpReasoningConfig", () => { + it("applies the reasoning selection through session/set_config_option and returns the value", () => + Effect.gen(function* () { + const { runtime, calls } = makeStubRuntime([reasoningSelectOption], () => Effect.void); + const applied = yield* applyAcpReasoningConfig({ + runtime, + selections: [{ id: "reasoning", value: "low" }], + mapError: () => "applied-error" as const, + }); + expect(applied).toBe("low"); + expect(calls).toEqual([{ configId: "reasoning", value: "low" }]); + }).pipe(Effect.runPromise)); + + it("is a no-op (and returns undefined) when no reasoning selection is present", () => + Effect.gen(function* () { + const { runtime, calls } = makeStubRuntime([reasoningSelectOption], () => Effect.void); + const applied = yield* applyAcpReasoningConfig({ + runtime, + selections: undefined, + mapError: () => "applied-error" as const, + }); + expect(applied).toBeUndefined(); + expect(calls).toEqual([]); + }).pipe(Effect.runPromise)); + + it("is a no-op when the ACP server declares no effort option", () => + Effect.gen(function* () { + const { runtime, calls } = makeStubRuntime([unrelatedOption], () => Effect.void); + const applied = yield* applyAcpReasoningConfig({ + runtime, + selections: [{ id: "reasoning", value: "low" }], + mapError: () => "applied-error" as const, + }); + expect(applied).toBeUndefined(); + expect(calls).toEqual([]); + }).pipe(Effect.runPromise)); + + it("maps the set_config_option error through mapError", () => + Effect.gen(function* () { + const { runtime } = makeStubRuntime([reasoningSelectOption], () => + Effect.fail(new Error("rpc failed")), + ); + const exit = yield* applyAcpReasoningConfig({ + runtime, + selections: [{ id: "reasoning", value: "low" }], + mapError: (cause) => ({ _tag: "SetConfigOptionFailed" as const, cause }), + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) as { _tag?: string }; + expect(error._tag).toBe("SetConfigOptionFailed"); + } + }).pipe(Effect.runPromise)); +}); diff --git a/apps/server/src/provider/acp/AcpReasoningConfig.ts b/apps/server/src/provider/acp/AcpReasoningConfig.ts new file mode 100644 index 00000000000..a5f68aae8f4 --- /dev/null +++ b/apps/server/src/provider/acp/AcpReasoningConfig.ts @@ -0,0 +1,199 @@ +import { + type ModelCapabilities, + type ProviderOptionSelection, + type SelectProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { + createModelCapabilities, + getProviderOptionStringSelectionValue, +} from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +/** + * T3 Code models "how hard should the agent think" as a single select option + * descriptor named `reasoning` (see `packages/contracts/src/model.ts`). ACP + * providers (Cursor, Grok, Hermes) expose the same notion as a session + * `config_option` whose id/name is "effort" or "reasoning". This module is the + * shared bridge between the two: it discovers an effort-shaped ACP config + * option, builds the `reasoning` descriptor for the T3 catalog, and maps a + * stored `reasoning` selection back to the ACP `session/set_config_option` + * call that applies it. + * + * The descriptor is only advertised when the ACP server actually declares an + * effort/reasoning select option, so a toggle never appears for a CLI that + * does not support one — no lying controls. Cursor keeps its own richer + * capability builder (`buildCursorCapabilitiesFromConfigOptions`) which also + * surfaces context/thinking/fast options; this module covers only the + * reasoning option, which is all Grok and Hermes apply today. + */ + +const REASONING_DESCRIPTOR_ID = "reasoning"; +const REASONING_DESCRIPTOR_LABEL = "Reasoning"; + +interface AcpSelectConfigEntry { + readonly value: string; + readonly name: string; +} + +function isAcpReasoningConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return ( + id === "effort" || + id === "reasoning" || + name === "effort" || + name === "reasoning" || + name.includes("effort") || + name.includes("reasoning") + ); +} + +/** The effort/reasoning-shaped select config option, if the server declares one. */ +export function findAcpReasoningConfigOption( + configOptions: ReadonlyArray | null | undefined, +): EffectAcpSchema.SessionConfigOption | undefined { + if (!configOptions || configOptions.length === 0) { + return undefined; + } + return configOptions.find( + (option) => option.type === "select" && isAcpReasoningConfigOption(option), + ); +} + +/** + * Flatten the nested-or-flat ACP select options into `{value, name}` entries, + * trimmed and de-duplicated by value. ACP select options can be parameterized + * (a group of `{value, name}` options) or flat (`{value, name}` directly); + * both shapes are verified against `effect-acp` and mirrored from + * `collectSessionConfigOptionValues` in `AcpRuntimeModel.ts`. + */ +function flattenAcpSelectConfigOptions( + configOption: EffectAcpSchema.SessionConfigOption, +): ReadonlyArray { + if (configOption.type !== "select") { + return []; + } + const entries: Array = []; + const seen = new Set(); + for (const entry of configOption.options) { + const candidates: ReadonlyArray = + "value" in entry + ? [{ value: entry.value, name: entry.name }] + : entry.options.map((option) => ({ value: option.value, name: option.name })); + for (const candidate of candidates) { + const value = candidate.value.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + const name = candidate.name.trim(); + entries.push({ value, name: name || value }); + } + } + return entries; +} + +/** + * Build the T3 `reasoning` select descriptor from the ACP server's declared + * effort/reasoning config option, surfacing the CLI's native values verbatim + * and preselecting the CLI's current value. Returns `undefined` when the + * server declares no such option, so no toggle is advertised. + */ +export function buildAcpReasoningOptionDescriptor( + configOptions: ReadonlyArray | null | undefined, +): SelectProviderOptionDescriptor | undefined { + const reasoningConfig = findAcpReasoningConfigOption(configOptions); + if (!reasoningConfig || reasoningConfig.type !== "select") { + return undefined; + } + const values = flattenAcpSelectConfigOptions(reasoningConfig); + if (values.length === 0) { + return undefined; + } + const currentValue = reasoningConfig.currentValue?.trim() || undefined; + const options = values.map(({ value, name }) => ({ + id: value, + label: name, + ...(currentValue && currentValue === value ? { isDefault: true } : {}), + })); + return { + id: REASONING_DESCRIPTOR_ID, + label: reasoningConfig.name?.trim() || REASONING_DESCRIPTOR_LABEL, + type: "select" as const, + options, + ...(currentValue ? { currentValue } : {}), + }; +} + +/** Capabilities carrying only the `reasoning` descriptor, or empty when none. */ +export function acpReasoningCapabilities( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + const descriptor = buildAcpReasoningOptionDescriptor(configOptions); + return createModelCapabilities({ + optionDescriptors: descriptor ? [descriptor] : [], + }); +} + +/** + * Map a stored `reasoning` selection back to the originating ACP config option + * id and value, so the adapter can apply it via `session/set_config_option`. + * Returns `undefined` when there is no effort-shaped option or no selection. + */ +export function resolveAcpReasoningConfigUpdate( + configOptions: ReadonlyArray | null | undefined, + selections: ReadonlyArray | null | undefined, +): { readonly configId: string; readonly value: string } | undefined { + const reasoningConfig = findAcpReasoningConfigOption(configOptions); + if (!reasoningConfig || reasoningConfig.type !== "select") { + return undefined; + } + const requested = getProviderOptionStringSelectionValue(selections, REASONING_DESCRIPTOR_ID); + if (!requested) { + return undefined; + } + const match = flattenAcpSelectConfigOptions(reasoningConfig).find( + (entry) => entry.value === requested || entry.name === requested, + ); + if (!match) { + return undefined; + } + return { configId: reasoningConfig.id, value: match.value }; +} + +/** Minimal slice of an ACP runtime that can read and write config options. */ +export interface AcpReasoningConfigRuntime { + readonly getConfigOptions: Effect.Effect>; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; +} + +/** + * Apply a stored `reasoning` selection to the live ACP session by issuing + * `session/set_config_option` for the originating config option. Returns the + * applied reasoning value so the adapter can echo it as `effort` on the wire. + * No-ops when the server declares no effort option or the selection is absent. + * + * Generic over the runtime's error type (`Err`) so the adapter's `mapError` + * receives the concrete ACP error and can fold it into its own error type. + */ +export function applyAcpReasoningConfig(input: { + readonly runtime: AcpReasoningConfigRuntime; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (cause: Err) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const configOptions = yield* input.runtime.getConfigOptions; + const update = resolveAcpReasoningConfigUpdate(configOptions, input.selections); + if (!update) { + return undefined; + } + yield* input.runtime + .setConfigOption(update.configId, update.value) + .pipe(Effect.mapError(input.mapError)); + return update.value; + }); +} diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 00000000000..7e51728766d --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,146 @@ +import { type HermesSettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const HERMES_HOME_ENV = "HERMES_HOME"; +// Host-side marker honored by `hermes acp` (acp_adapter/entry.py): skip +// starting the globally configured MCP servers from config.yaml because the +// host passes session MCP servers explicitly through session/new. +const HERMES_ACP_SKIP_CONFIGURED_MCP_ENV = "HERMES_ACP_SKIP_CONFIGURED_MCP"; +// Hermes' ACP server always advertises this terminal setup auth method +// (acp_adapter/auth.py). ACP reuses Hermes' own runtime credentials, so the +// authenticate response is a formality; T3 never renders the terminal. +const HERMES_AUTH_METHOD_TERMINAL_SETUP = "hermes-setup"; + +type HermesAcpRuntimeHermesSettings = Pick< + HermesSettings, + "binaryPath" | "homePath" | "profile" | "launchArgs" +>; + +export interface HermesAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + /** Skip Hermes' globally configured MCP servers (host passes them per session). */ + readonly skipConfiguredMcp?: boolean; +} + +/** + * Build the `hermes acp` spawn input. + * + * `--profile` is a Hermes global option, so it precedes the `acp` subcommand + * per the documented grammar `hermes [global-options] `. The + * profile flag is unverified on the ACP path (the docs only document it for + * the CLI) — if Hermes rejects it, sessions still start under the default + * profile. `launchArgs` are appended after `acp` so users can pass + * subcommand flags. + */ +export function buildHermesAcpSpawnInput( + hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + options?: { readonly skipConfiguredMcp?: boolean }, +): AcpSessionRuntime.AcpSpawnInput { + const profile = hermesSettings?.profile?.trim(); + const homePath = hermesSettings?.homePath?.trim(); + const env: NodeJS.ProcessEnv = { ...environment }; + if (homePath) { + env[HERMES_HOME_ENV] = homePath; + } + if (options?.skipConfiguredMcp) { + env[HERMES_ACP_SKIP_CONFIGURED_MCP_ENV] = "1"; + } + return { + command: hermesSettings?.binaryPath || "hermes", + args: [ + ...(profile ? (["--profile", profile] as const) : []), + "acp", + ...tokenizeCliArgs(hermesSettings?.launchArgs), + ], + cwd, + env, + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput( + input.hermesSettings, + input.cwd, + input.environment, + input.skipConfiguredMcp ? { skipConfiguredMcp: true } : undefined, + ), + authMethodId: HERMES_AUTH_METHOD_TERMINAL_SETUP, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +/** + * Read the current Hermes ACP model id from session setup. + * + * Hermes encodes model ids as `provider:model` (e.g. + * `openrouter:anthropic/claude-sonnet-4.6`), while T3 slugs use + * `provider/model`. The encoding is verified against Hermes' ACP server + * (`_build_model_state` in acp_adapter/server.py). + */ +export function currentHermesModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +/** + * Apply a T3 model selection to a Hermes ACP session. + * + * Hermes resolves `session/set_model` through `parse_model_input`, which + * accepts both `provider/model` and bare model ids, so the T3 slug is passed + * through unchanged. Because the session's `currentModelId` is always + * `provider:model` encoded, an exact match only no-ops when the user picked + * the current Hermes model verbatim; otherwise the switch is issued and + * Hermes no-ops internally when nothing changed. + */ +export function applyHermesAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..4248cbac3cf 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,7 +24,10 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; 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 { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; +import { OpenClawDriver, type OpenClawDriverEnv } from "./Drivers/OpenClawDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { PiAgentDriver, type PiAgentDriverEnv } from "./Drivers/PiAgentDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -33,11 +36,13 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = - | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | HermesDriverEnv + | OpenClawDriverEnv + | OpenCodeDriverEnv + | PiAgentDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +55,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): Promise { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "openclaw-mock-")); + const wrapperPath = NodePath.join(dir, "fake-openclaw.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockCommand)} ${JSON.stringify(mockGatewayPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +const openClawRuntimeTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-openclaw-runtime-test-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + // Close the runtime's own requirements so the merged layer graph is complete. + Layer.provideMerge(OpenClawRuntimeLive.pipe(Layer.provide(Layer.mergeAll(NodeServices.layer)))), +); + +const makeRuntime = () => Effect.service(OpenClawRuntime); + +describe("OpenClawRuntime", () => { + it.live("connects to an external gateway and completes the handshake", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ token: "secret-token" })); + const runtime = yield* makeRuntime(); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const connection = yield* runtime.connectToOpenClawGateway({ + binaryPath: "openclaw", + gatewayUrl: mock.url, + gatewayToken: "secret-token", + }); + assert.equal(connection.external, true); + assert.equal(connection.hello.serverVersion, "2026.8.1"); + assert.equal(connection.hello.protocol, 4); + assert.include(connection.hello.scopes, "operator.approvals"); + + const models = yield* connection.request("models.list", {}); + assert.ok(models && typeof models === "object"); + yield* connection.close; + return connection.hello.serverVersion; + }), + ); + assert.equal(result, "2026.8.1"); + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawRuntimeTestLayer)), + ); + + it.live("rejects an external gateway on token mismatch", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway({ token: "right-token" })); + const runtime = yield* makeRuntime(); + const error = yield* Effect.flip( + Effect.scoped( + runtime.connectToOpenClawGateway({ + binaryPath: "openclaw", + gatewayUrl: mock.url, + gatewayToken: "wrong-token", + }), + ), + ); + assert.equal(error._tag, "OpenClawRuntimeError"); + assert.include(error.detail, "token"); + yield* Effect.promise(() => mock.close()); + }).pipe(Effect.provide(openClawRuntimeTestLayer)), + ); + + it.live("spawns a gateway process and waits for protocol readiness", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockGatewayWrapper()); + const runtime = yield* makeRuntime(); + const result = yield* Effect.scoped( + Effect.gen(function* () { + const connection = yield* runtime.connectToOpenClawGateway({ + binaryPath: wrapperPath, + timeoutMs: 15_000, + }); + assert.equal(connection.external, false); + assert.equal(connection.hello.serverVersion, "2026.8.1-mock"); + assert.ok(connection.gatewayToken, "spawned gateway carries a generated token"); + const echo = yield* connection.request("models.list", {}); + assert.ok(echo && typeof echo === "object"); + yield* connection.close; + return connection.url; + }), + ); + assert.ok(result.startsWith("ws://127.0.0.1:")); + }).pipe(Effect.provide(openClawRuntimeTestLayer)), + ); + + it.live("fails cleanly when the gateway is unreachable", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => startMockOpenClawGateway()); + const url = mock.url; + yield* Effect.promise(() => mock.close()); + const runtime = yield* makeRuntime(); + const error = yield* Effect.flip( + Effect.scoped( + runtime.connectToOpenClawGateway({ + binaryPath: "openclaw", + gatewayUrl: url, + timeoutMs: 2_000, + }), + ), + ); + assert.equal(error._tag, "OpenClawRuntimeError"); + }).pipe(Effect.provide(openClawRuntimeTestLayer)), + ); + + it.live("runOpenClawCommand collects stdout and exit code", () => + Effect.gen(function* () { + const runtime = yield* makeRuntime(); + const result = yield* runtime.runOpenClawCommand({ + binaryPath: "sh", + args: ["-c", "echo hello-mock"], + }); + assert.equal(result.code, 0); + assert.include(result.stdout, "hello-mock"); + }).pipe(Effect.provide(openClawRuntimeTestLayer)), + ); +}); diff --git a/apps/server/src/provider/openclawRuntime.ts b/apps/server/src/provider/openclawRuntime.ts new file mode 100644 index 00000000000..185567dcf31 --- /dev/null +++ b/apps/server/src/provider/openclawRuntime.ts @@ -0,0 +1,776 @@ +/** + * openclawRuntime — shared runtime for the OpenClaw provider. + * + * OpenClaw is server-backed: a local **Gateway** daemon owns sessions, + * tools, and channel connections, and every client (Control UI, CLI, TUI) + * talks to it over a WebSocket control plane. This module owns that + * connection for T3 Code — either by spawning a fresh gateway process for + * this instance or by connecting to a user-configured `gatewayUrl`. + * + * Transport (verified against the OpenClaw docs + source): + * + * - WebSocket, JSON text frames. First frame MUST be `connect`; the gateway + * answers with a `res` carrying `hello-ok` (`server.version`, + * `features.methods`, `policy`, `auth`). + * See https://docs.openclaw.ai/gateway/protocol and + * https://docs.openclaw.ai/gateway/embedding + * - Requests: `{type:"req", id, method, params}` → + * `{type:"res", id, ok, payload|error}`. Agent runs are two-stage: an + * immediate `status:"accepted"` ack, then a final completion `res` for the + * same id (delivered here as a synthetic `openclaw.response` event so long + * runs are not blocked on a single request/response round trip). + * - Events: `{type:"event", event, payload, seq?}`. Run streaming arrives as + * `agent` events with `{runId, seq, stream, ts, data}` where `stream` is + * `lifecycle` (phase start/end/error), `assistant`, `thinking`, `tool`, + * `approval`, or `usage`. + * - Auth: shared-secret `gateway.auth.token` (`OPENCLAW_GATEWAY_TOKEN`). + * This adapter connects as the documented same-process backend client + * (`client.id: "gateway-client"`, `client.mode: "backend"`), which is + * allowed to omit the device identity on direct loopback connections when + * authenticated with the shared token + * (https://docs.openclaw.ai/gateway/protocol#pairing-and-local-trust). + * ASSUMPTION: a remote gateway that enforces device pairing will reject + * this connect; the error surfaces as a clear `ProviderAdapterRequestError`. + * + * Spawning: `openclaw gateway --port --allow-unconfigured` plus + * the embedding environment documented at https://docs.openclaw.ai/gateway/embedding + * (`OPENCLAW_NO_RESPAWN`, `OPENCLAW_DISABLE_BONJOUR`, + * `OPENCLAW_EXEC_SHELL_SNAPSHOT`, `OPENCLAW_SKIP_CHANNELS`). The spawned + * gateway gets its own token and an isolated `OPENCLAW_STATE_DIR` under the + * T3 instance state directory so it never touches a user's `~/.openclaw`. + * + * @module provider/openclawRuntime + */ +import * as NodeCrypto from "node:crypto"; + +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +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 Layer from "effect/Layer"; +import * as P from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { collectStreamAsString } from "./providerSnapshot.ts"; + +const OPENCLAW_RUNTIME_ERROR_TAG = "OpenClawRuntimeError"; +export class OpenClawRuntimeError extends Data.TaggedError(OPENCLAW_RUNTIME_ERROR_TAG)<{ + readonly operation: string; + readonly cause?: unknown; + readonly detail: string; +}> { + static readonly is = (u: unknown): u is OpenClawRuntimeError => + P.isTagged(u, OPENCLAW_RUNTIME_ERROR_TAG); +} + +export function openClawRuntimeErrorDetail(cause: unknown): string { + if (OpenClawRuntimeError.is(cause)) return cause.detail; + if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + return String(cause); +} + +export interface OpenClawCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenClawGatewayEventFrame { + readonly event: string; + readonly payload?: unknown; + readonly seq?: number; +} + +/** A late `res` frame for a request whose first response already resolved. */ +export interface OpenClawLateResponseFrame { + readonly id: string; + readonly ok: boolean; + readonly payload?: unknown; + readonly error?: unknown; +} + +export type OpenClawGatewayEvent = + | { readonly kind: "event"; readonly frame: OpenClawGatewayEventFrame } + | { readonly kind: "response"; readonly frame: OpenClawLateResponseFrame } + | { readonly kind: "closed"; readonly reason: string }; + +export interface OpenClawHelloInfo { + readonly protocol: number; + readonly serverVersion: string; + readonly connId: string; + readonly methods: ReadonlyArray; + readonly events: ReadonlyArray; + readonly scopes: ReadonlyArray; +} + +export interface OpenClawGatewayConnection { + readonly url: string; + readonly hello: OpenClawHelloInfo; + /** True when this connection is to a user-configured external gateway. */ + readonly external: boolean; + /** + * Shared-secret token used for this connection. Exposed so HTTP consumers + * (the OpenAI-compatible endpoints used by text generation) can reuse the + * same auth boundary as the WebSocket control plane. + */ + readonly gatewayToken: string | undefined; + /** + * Send one RPC. Resolves on the FIRST matching response frame (the + * `status:"accepted"` ack for agent runs); later frames for the same id + * surface on `events` as `response` frames. + */ + readonly request: ( + method: string, + params?: Record, + ) => Effect.Effect; + /** Inbound event frames + late response frames + close notifications. */ + readonly events: Stream.Stream; + readonly close: Effect.Effect; + /** Exit code of the spawned gateway process, or null for external gateways. */ + readonly exitCode: Effect.Effect | null; +} + +export interface OpenClawRuntimeShape { + /** + * Resolve the gateway for this instance: connect to `gatewayUrl` when set, + * otherwise spawn `binaryPath gateway --port ` and wait for protocol + * readiness. The child process lifetime is bound to the caller's scope. + */ + readonly connectToOpenClawGateway: (input: { + readonly binaryPath: string; + readonly gatewayUrl?: string; + readonly gatewayToken?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly stateDir?: string; + readonly launchArgs?: ReadonlyArray; + readonly port?: number; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly runOpenClawCommand: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; +} + +const DEFAULT_GATEWAY_PORT = 18_789; +const DEFAULT_GATEWAY_STARTUP_TIMEOUT_MS = 30_000; +const GATEWAY_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; +const PROTOCOL_VERSION = 4; + +const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write", "operator.approvals"] as const; + +function wsUrlForPort(port: number): string { + return `ws://${DEFAULT_HOSTNAME}:${port}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +interface PendingRequest { + readonly deferred: Deferred.Deferred< + { readonly ok: boolean; readonly payload?: unknown; readonly error?: unknown }, + OpenClawRuntimeError + >; +} + +/** + * Minimal gateway protocol client over the platform WebSocket. Kept as a + * plain class because it is a transport primitive; the Effect surface lives + * on the runtime service. + */ +class OpenClawWsClient { + private readonly pending = new Map(); + private readonly eventQueue: Queue.Queue; + private readonly socket: WebSocket; + private requestSeq = 0; + private readonly closed = new Set(); + + constructor(socket: WebSocket, eventQueue: Queue.Queue) { + this.socket = socket; + this.eventQueue = eventQueue; + } + + static connect( + url: string, + eventQueue: Queue.Queue, + ): Effect.Effect { + return Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch (cause) { + reject( + new OpenClawRuntimeError({ + operation: "ws.connect", + detail: `Failed to construct WebSocket for '${url}': ${openClawRuntimeErrorDetail(cause)}`, + cause, + }), + ); + return; + } + socket.addEventListener("open", () => { + resolve(new OpenClawWsClient(socket, eventQueue)); + }); + socket.addEventListener("error", () => { + const readyState = socket.readyState; + reject( + new OpenClawRuntimeError({ + operation: "ws.connect", + detail: + readyState === WebSocket.CONNECTING + ? `Gateway unreachable at '${url}' (connection refused or closed during handshake).` + : `Gateway WebSocket errored at '${url}'.`, + }), + ); + }); + }), + catch: (cause) => + OpenClawRuntimeError.is(cause) + ? cause + : new OpenClawRuntimeError({ + operation: "ws.connect", + detail: openClawRuntimeErrorDetail(cause), + cause, + }), + }); + } + + /** Register the message/close handlers that drive requests + events. */ + attach(): void { + this.socket.addEventListener("message", (event) => { + const raw = typeof event.data === "string" ? event.data : undefined; + if (raw === undefined) { + return; + } + let frame: unknown; + try { + frame = JSON.parse(raw) as unknown; + } catch { + return; + } + if (!isRecord(frame)) { + return; + } + const type = frame.type; + if (type === "res") { + this.handleResponse(frame); + return; + } + if (type === "event") { + const eventName = frame.event; + if (typeof eventName === "string") { + Effect.runSync( + Queue.offer(this.eventQueue, { + kind: "event", + frame: { + event: eventName, + ...(frame.payload !== undefined ? { payload: frame.payload } : {}), + ...(typeof frame.seq === "number" ? { seq: frame.seq } : {}), + }, + }), + ); + } + return; + } + }); + this.socket.addEventListener("close", (event) => { + const reason = `Gateway WebSocket closed (code ${event.code}${event.reason ? `: ${event.reason}` : ""}).`; + Effect.runSync(Queue.offer(this.eventQueue, { kind: "closed", reason })); + for (const pending of this.pending.values()) { + Effect.runSync( + Deferred.fail( + pending.deferred, + new OpenClawRuntimeError({ operation: "ws.close", detail: reason }), + ), + ); + } + this.pending.clear(); + }); + } + + private handleResponse(frame: Record): void { + const id = frame.id; + if (typeof id !== "string") { + return; + } + const pending = this.pending.get(id); + if (pending) { + this.pending.delete(id); + this.closed.add(id); + const ok = frame.ok === true; + const payload = frame.payload; + const error = frame.error; + Effect.runSync( + Deferred.succeed(pending.deferred, { + ok, + ...(payload !== undefined ? { payload } : {}), + ...(error !== undefined ? { error } : {}), + }), + ); + return; + } + if (this.closed.has(id)) { + // Late second-stage response (e.g. the final agent completion). + Effect.runSync( + Queue.offer(this.eventQueue, { + kind: "response", + frame: { + id, + ok: frame.ok === true, + ...(frame.payload !== undefined ? { payload: frame.payload } : {}), + ...(frame.error !== undefined ? { error: frame.error } : {}), + }, + }), + ); + return; + } + } + + request( + method: string, + params?: Record, + ): Effect.Effect< + { readonly ok: boolean; readonly payload?: unknown; readonly error?: unknown }, + OpenClawRuntimeError + > { + const client = this; + return Effect.gen(function* () { + const id = `t3-${method}-${++client.requestSeq}`; + const deferred = yield* Deferred.make< + { readonly ok: boolean; readonly payload?: unknown; readonly error?: unknown }, + OpenClawRuntimeError + >(); + client.pending.set(id, { deferred }); + const frame: Record = { type: "req", id, method }; + if (params !== undefined) { + frame.params = params; + } + try { + client.socket.send(JSON.stringify(frame)); + } catch (cause) { + client.pending.delete(id); + return yield* new OpenClawRuntimeError({ + operation: method, + detail: `Failed to send gateway request '${method}': ${openClawRuntimeErrorDetail(cause)}`, + cause, + }); + } + const timedOut = yield* Effect.timeoutOption( + Deferred.await(deferred), + GATEWAY_REQUEST_TIMEOUT_MS, + ); + if (timedOut._tag === "None") { + client.pending.delete(id); + return yield* new OpenClawRuntimeError({ + operation: method, + detail: `Gateway request '${method}' timed out after ${GATEWAY_REQUEST_TIMEOUT_MS}ms.`, + }); + } + return timedOut.value; + }); + } + + close(): void { + try { + this.socket.close(); + } catch { + // Already closed. + } + } +} + +const makeOpenClawRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + + const runOpenClawCommand: OpenClawRuntimeShape["runOpenClawCommand"] = (input) => + Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + input.args, + input.environment !== undefined ? { env: input.environment } : {}, + ); + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + shell: spawnCommand.shell, + ...(input.environment ? { env: input.environment } : { extendEnv: true }), + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + const exitCode = Number(code); + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { + return yield* new OpenClawRuntimeError({ + operation: "runOpenClawCommand", + detail: `spawn ${input.binaryPath} ENOENT`, + }); + } + return { stdout, stderr, code: exitCode } satisfies OpenClawCommandResult; + }).pipe( + Effect.scoped, + Effect.mapError((cause) => + OpenClawRuntimeError.is(cause) + ? cause + : new OpenClawRuntimeError({ + operation: "runOpenClawCommand", + detail: `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openClawRuntimeErrorDetail(cause)}`, + cause, + }), + ), + ); + + /** + * Establish a WS connection + handshake against a running gateway. + * Handles the retryable `UNAVAILABLE` (startup sidecars) connect error. + */ + const openGatewayConnection = (input: { + readonly url: string; + readonly gatewayToken?: string; + readonly external: boolean; + readonly exitCode: Effect.Effect | null; + }): Effect.Effect => + Effect.gen(function* () { + const eventQueue = yield* Queue.unbounded(); + const client = yield* OpenClawWsClient.connect(input.url, eventQueue); + client.attach(); + + const connectFrame: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "0.0.0", + platform: + hostPlatform === "win32" ? "win32" : hostPlatform === "darwin" ? "darwin" : "linux", + mode: "backend", + }, + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + caps: ["tool-events"], + ...(input.gatewayToken !== undefined && input.gatewayToken.length > 0 + ? { auth: { token: input.gatewayToken } } + : {}), + userAgent: "t3-code/openclaw-adapter", + }; + + const connectResult = yield* client.request("connect", connectFrame); + if (!connectResult.ok) { + client.close(); + return yield* new OpenClawRuntimeError({ + operation: "connect", + detail: openClawConnectErrorDetail(connectResult.error), + cause: connectResult.error, + }); + } + const hello = parseHelloInfo(connectResult.payload); + if (!hello) { + client.close(); + return yield* new OpenClawRuntimeError({ + operation: "connect", + detail: "Gateway hello-ok payload was missing or malformed.", + cause: connectResult.payload, + }); + } + + return { + url: input.url, + hello, + external: input.external, + gatewayToken: input.gatewayToken, + request: (method, params) => + client.request(method, params).pipe( + Effect.flatMap((response) => + response.ok + ? Effect.succeed(response.payload) + : new OpenClawRuntimeError({ + operation: method, + detail: openClawRpcErrorDetail(response.error), + cause: response.error, + }), + ), + ), + get events() { + return Stream.fromQueue(eventQueue); + }, + close: Effect.sync(() => client.close()), + exitCode: input.exitCode, + } satisfies OpenClawGatewayConnection; + }); + + const spawnGatewayProcess = (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + readonly port: number; + readonly stateDir?: string; + readonly launchArgs?: ReadonlyArray; + }): Effect.Effect< + { readonly exitCode: Effect.Effect; readonly token: string }, + OpenClawRuntimeError, + Scope.Scope + > => + Effect.gen(function* () { + const runtimeScope = yield* Scope.Scope; + const token = NodeCrypto.randomBytes(24).toString("base64url"); + const args = [ + "gateway", + "--port", + String(input.port), + "--allow-unconfigured", + ...(input.launchArgs ?? []), + ]; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + args, + input.environment !== undefined ? { env: input.environment } : {}, + ); + const environment: NodeJS.ProcessEnv = { + ...(input.environment ?? process.env), + OPENCLAW_GATEWAY_TOKEN: token, + OPENCLAW_NO_RESPAWN: "1", + OPENCLAW_DISABLE_BONJOUR: "1", + OPENCLAW_EXEC_SHELL_SNAPSHOT: "0", + OPENCLAW_SKIP_CHANNELS: "1", + ...(input.stateDir ? { OPENCLAW_STATE_DIR: input.stateDir } : {}), + }; + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, + env: environment, + extendEnv: false, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new OpenClawRuntimeError({ + operation: "spawnGatewayProcess", + detail: `Failed to spawn OpenClaw gateway process: ${openClawRuntimeErrorDetail(cause)}`, + cause, + }), + ), + ); + + const killGatewayProcessGroup = (signal: NodeJS.Signals) => + hostPlatform === "win32" + ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) + : Effect.sync(() => { + try { + process.kill(-Number(child.pid), signal); + } catch { + // The direct child may already have exited; the group kill is + // best-effort cleanup for any gateway process left in that group. + } + }); + const terminateChild = killGatewayProcessGroup("SIGTERM").pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.andThen(killGatewayProcessGroup("SIGKILL")), + Effect.ignore, + ); + yield* Scope.addFinalizer(runtimeScope, terminateChild); + + // Watch the exit code through a Deferred: `child.exitCode` is not + // interruptible, so awaiting it directly inside the readiness loop's + // timeout would block until the (long-lived) gateway actually exits. + const exitDeferred = yield* Deferred.make(); + yield* child.exitCode.pipe( + Effect.flatMap((code) => Deferred.succeed(exitDeferred, Number(code))), + Effect.ignore, + Effect.forkIn(runtimeScope), + ); + + return { + exitCode: Deferred.await(exitDeferred), + token, + }; + }); + + const connectToOpenClawGateway: OpenClawRuntimeShape["connectToOpenClawGateway"] = (input) => { + const gatewayUrl = input.gatewayUrl?.trim(); + if (gatewayUrl) { + // External gateway: no process to own, no scope interaction. + const token = input.gatewayToken?.trim(); + return openGatewayConnection({ + url: normalizeGatewayWsUrl(gatewayUrl), + ...(token !== undefined && token.length > 0 ? { gatewayToken: token } : {}), + external: true, + exitCode: null, + }).pipe( + Effect.mapError((cause) => + OpenClawRuntimeError.is(cause) + ? cause + : new OpenClawRuntimeError({ + operation: "connectToOpenClawGateway", + detail: openClawRuntimeErrorDetail(cause), + cause, + }), + ), + ); + } + + return Effect.gen(function* () { + const timeoutMs = input.timeoutMs ?? DEFAULT_GATEWAY_STARTUP_TIMEOUT_MS; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenClawRuntimeError({ + operation: "connectToOpenClawGateway", + detail: `Failed to find an available gateway port: ${openClawRuntimeErrorDetail(cause)}`, + cause, + }), + ), + )); + const spawned = yield* spawnGatewayProcess({ + binaryPath: input.binaryPath, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + port, + ...(input.stateDir !== undefined ? { stateDir: input.stateDir } : {}), + ...(input.launchArgs !== undefined ? { launchArgs: input.launchArgs } : {}), + }); + const url = wsUrlForPort(port); + + // Wait for protocol readiness: repeatedly open the WS + handshake until + // hello-ok arrives or the child exits (embedding readiness contract). + const deadline = Date.now() + timeoutMs; + let lastError: OpenClawRuntimeError | undefined; + let exitCode: number | undefined; + while (Date.now() < deadline) { + const attempt = yield* Effect.exit( + openGatewayConnection({ + url, + gatewayToken: spawned.token, + external: false, + exitCode: spawned.exitCode, + }), + ); + if (Exit.isSuccess(attempt)) { + return attempt.value; + } + const cause = Cause.squash(attempt.cause); + lastError = OpenClawRuntimeError.is(cause) ? cause : undefined; + const exitResult = yield* Effect.exit( + Effect.timeoutOption(spawned.exitCode, "250 millis").pipe( + Effect.map((result) => (result._tag === "Some" ? result.value : undefined)), + ), + ); + if (Exit.isSuccess(exitResult) && exitResult.value !== undefined) { + exitCode = exitResult.value; + break; + } + yield* Effect.sleep("250 millis"); + } + if (exitCode !== undefined) { + return yield* new OpenClawRuntimeError({ + operation: "connectToOpenClawGateway", + detail: `OpenClaw gateway exited before becoming ready (code ${exitCode}).`, + }); + } + return yield* new OpenClawRuntimeError({ + operation: "connectToOpenClawGateway", + detail: `Timed out waiting for the OpenClaw gateway to start after ${timeoutMs}ms.${lastError ? ` Last error: ${lastError.detail}` : ""}`, + cause: lastError, + }); + }); + }; + + return { + connectToOpenClawGateway, + runOpenClawCommand, + } satisfies OpenClawRuntimeShape; +}); + +function normalizeGatewayWsUrl(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) { + return trimmed; + } + return `ws://${trimmed}`; +} + +function parseHelloInfo(payload: unknown): OpenClawHelloInfo | null { + if (!isRecord(payload)) { + return null; + } + const server = isRecord(payload.server) ? payload.server : {}; + const features = isRecord(payload.features) ? payload.features : {}; + const auth = isRecord(payload.auth) ? payload.auth : {}; + const serverVersion = typeof server.version === "string" ? server.version : ""; + const connId = typeof server.connId === "string" ? server.connId : ""; + const protocol = typeof payload.protocol === "number" ? payload.protocol : 0; + if (serverVersion.length === 0 || protocol === 0) { + return null; + } + const asStringArray = (value: unknown): ReadonlyArray => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; + return { + protocol, + serverVersion, + connId, + methods: asStringArray(features.methods), + events: asStringArray(features.events), + scopes: asStringArray(auth.scopes), + }; +} + +function openClawConnectErrorDetail(error: unknown): string { + if (isRecord(error)) { + const code = typeof error.code === "string" ? error.code : "unknown"; + const message = typeof error.message === "string" ? error.message : ""; + if (code === "PAIRING_REQUIRED") { + return "The OpenClaw gateway requires device pairing for this connection. Start T3's own gateway (leave Gateway URL empty) or approve this client on the gateway host with `openclaw devices approve`."; + } + if (code === "UNAUTHORIZED" || code === "FORBIDDEN" || code === "AUTH_TOKEN_MISMATCH") { + return "The OpenClaw gateway rejected the connection token. Check the Gateway token setting."; + } + return message.length > 0 ? `${code}: ${message}` : `Gateway connect failed (${code}).`; + } + return "Gateway connect failed."; +} + +function openClawRpcErrorDetail(error: unknown): string { + if (isRecord(error)) { + const code = typeof error.code === "string" ? error.code : "unknown"; + const message = typeof error.message === "string" ? error.message : ""; + if (message.length > 0) { + return `${code}: ${message}`; + } + const details = error.details; + if (isRecord(details) && typeof details.reason === "string") { + return `${code}: ${details.reason}`; + } + return `Gateway request failed (${code}).`; + } + return "Gateway request failed."; +} + +export class OpenClawRuntime extends Context.Service()( + "t3/provider/openclawRuntime", +) {} + +export const OpenClawRuntimeLive = Layer.effect(OpenClawRuntime, makeOpenClawRuntime).pipe( + Layer.provideMerge(NetService.layer), +); diff --git a/apps/server/src/provider/testUtils/openclawMockGateway.ts b/apps/server/src/provider/testUtils/openclawMockGateway.ts new file mode 100644 index 00000000000..9d882d0ad6e --- /dev/null +++ b/apps/server/src/provider/testUtils/openclawMockGateway.ts @@ -0,0 +1,498 @@ +/** + * openclawMockGateway — an in-process stand-in for the OpenClaw Gateway used + * by the OpenClaw provider tests. + * + * The real `openclaw` binary is not installed in CI or on contributor + * machines, so tests drive the adapter/runtime against this scripted WebSocket + * server. It implements just enough of the gateway protocol (v4) to exercise + * the T3 adapter: + * + * - `connect` handshake → `hello-ok` + * - `sessions.create` / `sessions.describe` / `sessions.delete` + * - `agent` → accepted ack, streamed `agent` events (lifecycle/assistant/ + * thinking/tool/approval), then a late completion `res` + * - `chat.abort`, `chat.history`, `exec.approval.resolve`, `models.list` + * + * The server speaks RFC 6455 directly over `node:http` because the repo does + * not depend on a WebSocket server package. + * + * @module provider/testUtils/openclawMockGateway + */ +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +export interface OpenClawMockGatewayOptions { + /** Port to bind; defaults to an ephemeral port. */ + readonly port?: number; + readonly token?: string; + readonly rejectConnect?: boolean; + readonly connectErrorCode?: string; + readonly failSessionCreate?: boolean; + readonly sessionNotFoundOnDescribe?: boolean; + readonly emitThinking?: boolean; + readonly emitToolEvents?: boolean; + readonly emitApproval?: boolean; + /** + * When `emitApproval` is on, also emit the automatic `resolved` approval + * event. Set to `false` to keep the approval pending so a test can drive + * `exec.approval.resolve` through the adapter first. + */ + readonly resolveApproval?: boolean; + readonly hangAgent?: boolean; + readonly failAgent?: boolean; + readonly respondToHistory?: boolean; + readonly serverVersion?: string; + readonly modelCatalog?: ReadonlyArray<{ readonly id: string; readonly name?: string }>; + /** Delays agent streaming by this many ms before emitting lifecycle end. */ + readonly agentDelayMs?: number; +} + +export interface OpenClawMockGatewayHandle { + readonly url: string; + readonly port: number; + /** Every request frame received by the mock, in order. */ + readonly requests: Array<{ + readonly id: string; + readonly method: string; + readonly params?: unknown; + }>; + readonly close: () => Promise; +} + +interface MockSocket { + readonly socket: NodeNet.Socket; + readonly send: (text: string) => void; +} + +const MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +function acceptKey(key: string | undefined): string { + return NodeCrypto.createHash("sha1") + .update(`${key ?? ""}${MAGIC_GUID}`) + .digest("base64"); +} + +function encodeFrame(text: string): Buffer { + const payload = Buffer.from(text, "utf8"); + const header: Array = [0x81]; + if (payload.length < 126) { + header.push(payload.length); + } else if (payload.length < 65536) { + header.push(126, (payload.length >> 8) & 0xff, payload.length & 0xff); + } else { + header.push(127); + for (let i = 7; i >= 0; i -= 1) { + header.push(Math.floor(payload.length / 2 ** (8 * i)) & 0xff); + } + } + return Buffer.concat([Buffer.from(header), payload]); +} + +class FrameDecoder { + private buffer = Buffer.alloc(0); + + push(chunk: Buffer): Array<{ readonly opcode: number; readonly payload: Buffer }> { + this.buffer = Buffer.concat([this.buffer, chunk]); + const frames: Array<{ opcode: number; payload: Buffer }> = []; + for (;;) { + const frame = this.tryDecode(); + if (!frame) { + break; + } + frames.push(frame); + } + return frames; + } + + private tryDecode(): { readonly opcode: number; readonly payload: Buffer } | null { + const buf = this.buffer; + if (buf.length < 2) { + return null; + } + const opcode = buf[0]! & 0x0f; + const masked = (buf[1]! & 0x80) !== 0; + let length = buf[1]! & 0x7f; + let offset = 2; + if (length === 126) { + if (buf.length < 4) return null; + length = buf.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (buf.length < 10) return null; + length = Number(buf.readBigUInt64BE(2)); + offset = 10; + } + const maskLength = masked ? 4 : 0; + if (buf.length < offset + maskLength + length) { + return null; + } + const mask = masked ? buf.subarray(offset, offset + 4) : undefined; + const payload = Buffer.from(buf.subarray(offset + maskLength, offset + maskLength + length)); + if (mask) { + for (let i = 0; i < payload.length; i += 1) { + payload[i] = payload[i]! ^ mask[i % 4]!; + } + } + this.buffer = buf.subarray(offset + maskLength + length); + return { opcode, payload }; + } +} + +export function startMockOpenClawGateway( + options: OpenClawMockGatewayOptions = {}, +): Promise { + const requests: OpenClawMockGatewayHandle["requests"] = []; + const sockets = new Set(); + const server = NodeHttp.createServer(); + + server.on("upgrade", (req, socket) => { + const key = req.headers["sec-websocket-key"]; + if (!key) { + socket.destroy(); + return; + } + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptKey(key)}`, + "\r\n", + ].join("\r\n"), + ); + const mockSocket: MockSocket = { + socket: socket as NodeNet.Socket, + send: (text) => { + if (!socket.destroyed) { + socket.write(encodeFrame(text)); + } + }, + }; + sockets.add(mockSocket); + + const decoder = new FrameDecoder(); + let nextId = 1; + let agentRunCounter = 0; + let lastCreatedSessionKey: string | undefined; + + const sendResponse = (id: string, payload?: unknown) => { + mockSocket.send( + JSON.stringify({ + type: "res", + id, + ok: true, + ...(payload !== undefined ? { payload } : {}), + }), + ); + }; + const sendErrorResponse = (id: string, code: string, message: string) => { + mockSocket.send(JSON.stringify({ type: "res", id, ok: false, error: { code, message } })); + }; + const sendEvent = (event: string, payload?: unknown) => { + mockSocket.send( + JSON.stringify({ type: "event", event, ...(payload !== undefined ? { payload } : {}) }), + ); + }; + + const emitAgentRun = (id: string, message: string) => { + const runId = `mock-run-${++agentRunCounter}`; + const sessionKey = lastCreatedSessionKey ?? "t3-mock-session"; + sendResponse(id, { runId, status: "accepted" }); + + if (options.hangAgent) { + return; + } + const emit = () => { + sendEvent("agent", { + runId, + seq: 1, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "start" }, + sessionKey, + }); + if (options.emitThinking) { + sendEvent("agent", { + runId, + seq: 2, + stream: "thinking", + ts: Date.now(), + data: { delta: "mock thinking" }, + sessionKey, + }); + } + if (options.emitToolEvents) { + sendEvent("agent", { + runId, + seq: 3, + stream: "tool", + ts: Date.now(), + data: { + state: "start", + toolName: "bash", + toolCallId: "mock-call-1", + args: { command: "ls" }, + }, + sessionKey, + }); + sendEvent("agent", { + runId, + seq: 4, + stream: "tool", + ts: Date.now(), + data: { + state: "end", + toolName: "bash", + toolCallId: "mock-call-1", + result: "file.txt", + isError: false, + }, + sessionKey, + }); + } + if (options.emitApproval) { + sendEvent("agent", { + runId, + seq: 5, + stream: "approval", + ts: Date.now(), + data: { + phase: "requested", + kind: "exec", + approvalId: "mock-approval-1", + title: "Approve command", + command: "rm -rf /tmp/x", + toolCallId: "mock-call-1", + }, + sessionKey, + }); + if (options.resolveApproval !== false) { + sendEvent("agent", { + runId, + seq: 6, + stream: "approval", + ts: Date.now(), + data: { + phase: "resolved", + kind: "exec", + approvalId: "mock-approval-1", + status: "approved", + }, + sessionKey, + }); + } + } + sendEvent("agent", { + runId, + seq: 7, + stream: "assistant", + ts: Date.now(), + data: { delta: `hello from mock openclaw (${message})` }, + sessionKey, + }); + if (options.failAgent) { + sendEvent("agent", { + runId, + seq: 8, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "error", error: "mock agent failure" }, + sessionKey, + }); + return; + } + sendEvent("agent", { + runId, + seq: 8, + stream: "lifecycle", + ts: Date.now(), + data: { phase: "end" }, + sessionKey, + }); + // Late completion response for the same request id. + mockSocket.send( + JSON.stringify({ + type: "res", + id, + ok: true, + payload: { runId, status: "ok", summary: "done" }, + }), + ); + }; + if (options.agentDelayMs && options.agentDelayMs > 0) { + setTimeout(emit, options.agentDelayMs); + } else { + emit(); + } + }; + + socket.on("data", (chunk) => { + for (const frame of decoder.push(chunk)) { + if (frame.opcode === 8) { + socket.end(); + sockets.delete(mockSocket); + return; + } + if (frame.opcode === 9) { + mockSocket.send( + JSON.stringify({ type: "event", event: "tick", payload: { ts: Date.now() } }), + ); + continue; + } + if (frame.opcode !== 1) { + continue; + } + let message: Record; + try { + message = JSON.parse(frame.payload.toString("utf8")) as Record; + } catch { + continue; + } + if (message.type !== "req") { + continue; + } + const id = typeof message.id === "string" ? message.id : `id-${nextId++}`; + const method = typeof message.method === "string" ? message.method : ""; + const params = + typeof message.params === "object" && message.params !== null + ? (message.params as Record) + : {}; + requests.push({ id, method, params }); + + switch (method) { + case "connect": { + if (options.rejectConnect) { + sendErrorResponse( + id, + options.connectErrorCode ?? "UNAUTHORIZED", + "mock gateway rejected the connection", + ); + socket.end(); + sockets.delete(mockSocket); + return; + } + const token = (params.auth as Record | undefined)?.token; + if (options.token && token !== options.token) { + sendErrorResponse(id, "AUTH_TOKEN_MISMATCH", "mock token mismatch"); + socket.end(); + sockets.delete(mockSocket); + return; + } + sendResponse(id, { + type: "hello-ok", + protocol: 4, + server: { version: options.serverVersion ?? "2026.8.1", connId: "mock-conn" }, + features: { + methods: [ + "sessions.create", + "sessions.describe", + "agent", + "chat.abort", + "chat.history", + "models.list", + "exec.approval.resolve", + ], + events: ["agent", "tick", "shutdown"], + }, + snapshot: { presence: [], health: { ok: true } }, + auth: { + role: "operator", + scopes: ["operator.read", "operator.write", "operator.approvals"], + }, + policy: { maxPayload: 26214400, maxBufferedBytes: 52428800, tickIntervalMs: 15000 }, + }); + return; + } + case "sessions.create": { + if (options.failSessionCreate) { + sendErrorResponse(id, "INTERNAL", "mock session create failed"); + return; + } + const key = typeof params.key === "string" ? params.key : `t3-mock-${nextId++}`; + lastCreatedSessionKey = key; + sendResponse(id, { + ok: true, + key, + sessionId: `mock-session-id-${key}`, + entry: { key }, + }); + return; + } + case "sessions.describe": { + if (options.sessionNotFoundOnDescribe) { + sendErrorResponse(id, "NOT_FOUND", "mock session not found"); + return; + } + sendResponse(id, { key: params.key, sessionId: `mock-session-id-${params.key}` }); + return; + } + case "agent": { + emitAgentRun(id, typeof params.message === "string" ? params.message : ""); + return; + } + case "chat.abort": { + sendResponse(id, { ok: true, aborted: true }); + return; + } + case "chat.history": { + if (options.respondToHistory === false) { + sendErrorResponse(id, "NOT_FOUND", "mock history unavailable"); + return; + } + sendResponse(id, { + messages: [ + { id: "mock-user-1", role: "user", content: "hello" }, + { id: "mock-msg-1", role: "assistant", content: "hello from mock openclaw" }, + ], + }); + return; + } + case "exec.approval.resolve": { + sendResponse(id, { ok: true, id: params.id, decision: params.decision }); + return; + } + case "models.list": { + sendResponse(id, { + models: options.modelCatalog ?? [ + { id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5" }, + ], + }); + return; + } + default: { + sendErrorResponse(id, "UNKNOWN_METHOD", `unknown method ${method}`); + return; + } + } + } + }); + socket.on("close", () => { + sockets.delete(mockSocket); + }); + }); + + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("mock gateway failed to bind")); + return; + } + resolve({ + url: `ws://127.0.0.1:${address.port}`, + port: address.port, + requests, + close: () => + new Promise((closeResolve) => { + for (const { socket } of sockets) { + socket.destroy(); + } + server.close(() => closeResolve()); + }), + }); + }); + }); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8628aeef314..42cb0df4a9a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5004,6 +5004,156 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.makeDirectory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-mkdir-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsMakeDirectory]({ + cwd: workspaceDir, + relativePath: "nested/deep/folder", + }), + ), + ); + + assert.equal(response.relativePath, "nested/deep/folder"); + const stat = yield* fs.stat(path.join(workspaceDir, "nested", "deep", "folder")); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.makeDirectory errors", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-mkdir-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsMakeDirectory]({ + cwd: workspaceDir, + relativePath: "../escape-dir", + }), + ).pipe(Effect.result), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectMakeDirectoryError") { + assert.fail("Expected a ProjectMakeDirectoryError"); + } + const makeError = result.failure; + assert.equal( + makeError.message, + `Failed to create workspace directory '../escape-dir' in '${workspaceDir}'.`, + ); + assert.equal(makeError.cwd, workspaceDir); + assert.equal(makeError.relativePath, "../escape-dir"); + assert.equal(makeError.failure, "workspace_path_outside_root"); + assert.isDefined(makeError.cause); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.deleteFile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-delete-" }); + yield* fs.writeFileString(path.join(workspaceDir, "notes.md"), "delete me\n"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsDeleteFile]({ + cwd: workspaceDir, + relativePath: "notes.md", + }), + ), + ); + + assert.equal(response.relativePath, "notes.md"); + const stat = yield* fs + .stat(path.join(workspaceDir, "notes.md")) + .pipe(Effect.orElseSucceed(() => null)); + assert.isNull(stat); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.deleteFile recursively for directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-delete-" }); + yield* fs.makeDirectory(path.join(workspaceDir, "src", "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(workspaceDir, "src", "a.ts"), "a\n"); + yield* fs.writeFileString(path.join(workspaceDir, "src", "nested", "b.ts"), "b\n"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsDeleteFile]({ + cwd: workspaceDir, + relativePath: "src", + recursive: true, + }), + ), + ); + + assert.equal(response.relativePath, "src"); + const stat = yield* fs + .stat(path.join(workspaceDir, "src")) + .pipe(Effect.orElseSucceed(() => null)); + assert.isNull(stat); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.deleteFile errors for non-recursive directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-delete-" }); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString(path.join(workspaceDir, "src", "a.ts"), "a\n"); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsDeleteFile]({ + cwd: workspaceDir, + relativePath: "src", + }), + ).pipe(Effect.result), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectDeleteFileError") { + assert.fail("Expected a ProjectDeleteFileError"); + } + const deleteError = result.failure; + assert.equal( + deleteError.message, + `Failed to delete workspace file 'src' in '${workspaceDir}'.`, + ); + assert.equal(deleteError.cwd, workspaceDir); + assert.equal(deleteError.relativePath, "src"); + assert.equal(deleteError.failure, "directory_requires_recursive"); + assert.isDefined(deleteError.cause); + const stat = yield* fs.stat(path.join(workspaceDir, "src")); + assert.equal(stat.type, "Directory"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc shell.openInEditor", () => Effect.gen(function* () { let openedInput: { cwd: string; editor: EditorId } | null = null; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..a2792077ce2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import * as OpenClawRuntime from "./provider/openclawRuntime.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -387,8 +388,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level - // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // keeps a single Live for all opencode consumers. Same story for + // `OpenClawDriver.create()`, which yields `OpenClawRuntime`. + Layer.provideMerge( + Layer.mergeAll(OpenCodeRuntime.OpenCodeRuntimeLive, OpenClawRuntime.OpenClawRuntimeLive), + ), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), diff --git a/apps/server/src/textGeneration/HermesTextGeneration.ts b/apps/server/src/textGeneration/HermesTextGeneration.ts new file mode 100644 index 00000000000..7a83e22e08e --- /dev/null +++ b/apps/server/src/textGeneration/HermesTextGeneration.ts @@ -0,0 +1,288 @@ +/** + * HermesTextGeneration — text generation via the Hermes CLI in scripted mode. + * + * Implements the same `TextGeneration` service contract as the other + * providers but delegates to `hermes -z "" --model `. `-z` is + * Hermes' pure one-shot entry point: single prompt in, final response text + * out, nothing else on stdout or stderr (documented in the Hermes CLI + * reference). Hermes has no `--output-format json` flag, so the prompt asks + * the model to return a single JSON object and we extract + decode it from + * the printed reply — the same approach PiAgentTextGeneration and + * GrokTextGeneration use. + * + * @module textGeneration/HermesTextGeneration + */ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { type HermesSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +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 { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const HERMES_TIMEOUT_MS = 180_000; +const HERMES_HOME_ENV = "HERMES_HOME"; + +export const makeHermesTextGeneration = Effect.fn("makeHermesTextGeneration")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("hermes", operation, cause, "Failed to collect process output"), + ), + ); + + /** + * Spawn the Hermes CLI in `-z` scripted mode with a JSON-producing prompt + * and return the parsed, schema-validated result. + */ + const runHermesJson = Effect.fn("runHermesJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const runHermesCommand = Effect.fn("runHermesJson.runHermesCommand")(function* () { + const profile = hermesSettings.profile?.trim(); + const homePath = hermesSettings.homePath?.trim(); + const spawnCommand = yield* resolveSpawnCommand( + hermesSettings.binaryPath || "hermes", + [ + ...(profile ? (["--profile", profile] as const) : []), + "-z", + prompt, + "--model", + modelSelection.model, + ], + { + env: { + ...environment, + ...(homePath ? { [HERMES_HOME_ENV]: homePath } : {}), + }, + }, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: { + ...environment, + ...(homePath ? { [HERMES_HOME_ENV]: homePath } : {}), + }, + cwd, + shell: spawnCommand.shell, + }); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("hermes", operation, cause, "Failed to spawn Hermes CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError("hermes", operation, cause, "Failed to read Hermes CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Hermes CLI command failed: ${detail}` + : `Hermes CLI command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runHermesCommand().pipe( + Effect.scoped, + Effect.timeoutOption(HERMES_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Hermes CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const trimmed = rawStdout.trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: "Hermes returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes returned invalid structured output.", + cause, + }), + ), + }), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("HermesTextGeneration.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* runHermesJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + 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("HermesTextGeneration.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* runHermesJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("HermesTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("HermesTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/OpenClawTextGeneration.ts b/apps/server/src/textGeneration/OpenClawTextGeneration.ts new file mode 100644 index 00000000000..ae2b56f3e8a --- /dev/null +++ b/apps/server/src/textGeneration/OpenClawTextGeneration.ts @@ -0,0 +1,287 @@ +/** + * OpenClawTextGeneration — text generation via the gateway's OpenAI-compatible + * HTTP API. + * + * OpenClaw exposes `POST /v1/chat/completions` on the same port as the + * WebSocket control plane (https://docs.openclaw.ai/gateway#openai-compatible-endpoints). + * This service drives the shared gateway holder (see + * {@link OpenClawGatewayHolder}) so a spawned gateway is started exactly once + * per instance, then posts one-shot prompts and extracts the JSON payload the + * same way the Pi/Grok text generation services do. + * + * Assumptions (documented because the HTTP auth header contract is not pinned + * in the public docs): + * + * - The endpoints use the same shared-secret auth boundary as the rest of the + * gateway HTTP API. With `gateway.auth.mode: "token"` we send + * `Authorization: Bearer `; a spawned gateway always has a generated + * token, an external gateway uses the configured `gatewayToken`. + * - The standard `model` field selects the default agent; a `provider/model` + * slug is forwarded via the documented `x-openclaw-model` override header. + * + * @module textGeneration/OpenClawTextGeneration + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + TextGenerationError, + type ModelSelection, + type OpenClawSettings, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { type OpenClawGatewayHolder } from "../provider/Layers/OpenClawAdapter.ts"; + +const OPENCLAW_TEXT_GENERATION_TIMEOUT_MS = 180_000; + +export interface OpenClawTextGenerationLiveOptions { + /** Shared gateway connection holder, owned by the driver. */ + readonly gateway: OpenClawGatewayHolder; + readonly environment?: NodeJS.ProcessEnv; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +function httpUrlFromGatewayUrl(wsUrl: string): string { + if (wsUrl.startsWith("wss://")) { + return `https://${wsUrl.slice("wss://".length)}`; + } + if (wsUrl.startsWith("ws://")) { + return `http://${wsUrl.slice("ws://".length)}`; + } + return wsUrl; +} + +function extractChatCompletionText(payload: unknown): string { + if (!isRecord(payload)) { + return ""; + } + const choices = payload.choices; + if (!Array.isArray(choices)) { + return ""; + } + const message = choices[0] && isRecord(choices[0]) ? choices[0].message : undefined; + if (isRecord(message)) { + const content = message.content; + if (typeof content === "string") { + return content; + } + } + return ""; +} + +export const makeOpenClawTextGeneration = Effect.fn("makeOpenClawTextGeneration")(function* ( + openClawSettings: OpenClawSettings, + options: OpenClawTextGenerationLiveOptions, +) { + const httpClient = yield* HttpClient.HttpClient; + + const runOpenClawJson = Effect.fn("runOpenClawJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + }): Effect.fn.Return { + const connection = yield* options.gateway + .acquire({ + binaryPath: openClawSettings.binaryPath, + ...(openClawSettings.gatewayUrl?.trim() ? { gatewayUrl: openClawSettings.gatewayUrl } : {}), + ...(openClawSettings.gatewayToken?.trim() + ? { gatewayToken: openClawSettings.gatewayToken } + : {}), + ...(options.environment !== undefined ? { environment: options.environment } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause.detail, + cause, + }), + ), + ); + + const baseUrl = httpUrlFromGatewayUrl(connection.url); + const modelSlug = input.modelSelection.model; + const body = { + model: "openclaw", + messages: [{ role: "user", content: input.prompt }], + }; + let request = HttpClientRequest.post(`${baseUrl}/v1/chat/completions`).pipe( + HttpClientRequest.setHeaders({ + "content-type": "application/json", + ...(modelSlug.includes("/") ? { "x-openclaw-model": modelSlug } : {}), + }), + HttpClientRequest.bodyJsonUnsafe(body), + ); + if (connection.gatewayToken) { + request = request.pipe(HttpClientRequest.bearerToken(connection.gatewayToken)); + } + + const timedOut = yield* httpClient.execute(request).pipe( + Effect.timeoutOption(OPENCLAW_TEXT_GENERATION_TIMEOUT_MS), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: `OpenClaw gateway chat completion request failed: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + if (timedOut._tag === "None") { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenClaw gateway chat completion request timed out.", + }); + } + const response = timedOut.value; + + const bodyResult = yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenClaw gateway returned an invalid chat completion payload.", + cause, + }), + ), + ); + const rawText = extractChatCompletionText(bodyResult).trim(); + if (rawText.length === 0) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenClaw returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawText)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenClaw returned invalid structured output.", + cause, + }), + ), + }), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenClawTextGeneration.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* runOpenClawJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + 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("OpenClawTextGeneration.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* runOpenClawJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenClawTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenClawJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenClawTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runOpenClawJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/PiAgentTextGeneration.ts b/apps/server/src/textGeneration/PiAgentTextGeneration.ts new file mode 100644 index 00000000000..cbba3f8645e --- /dev/null +++ b/apps/server/src/textGeneration/PiAgentTextGeneration.ts @@ -0,0 +1,268 @@ +/** + * PiAgentTextGeneration — text generation via the pi CLI in print mode. + * + * Implements the same `TextGeneration` service contract as the other + * providers but delegates to `pi -p "" --model `. pi has no + * `--output-format json` flag (unlike Claude/Codex), so the prompt asks the + * model to return a single JSON object and we extract + decode it from the + * printed reply — the same approach GrokTextGeneration uses. + * + * @module textGeneration/PiAgentTextGeneration + */ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { type PiAgentSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +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 { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const PI_TIMEOUT_MS = 180_000; + +export const makePiAgentTextGeneration = Effect.fn("makePiAgentTextGeneration")(function* ( + piSettings: PiAgentSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("pi", operation, cause, "Failed to collect process output"), + ), + ); + + /** + * Spawn the pi CLI in print mode with a JSON-producing prompt and return + * the parsed, schema-validated result. + */ + const runPiJson = Effect.fn("runPiJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const runPiCommand = Effect.fn("runPiJson.runPiCommand")(function* () { + const spawnCommand = yield* resolveSpawnCommand( + piSettings.binaryPath || "pi", + ["-p", prompt, "--model", modelSelection.model], + { env: environment }, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + cwd, + shell: spawnCommand.shell, + }); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("pi", operation, cause, "Failed to spawn Pi CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError("pi", operation, cause, "Failed to read Pi CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Pi CLI command failed: ${detail}` + : `Pi CLI command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runPiCommand().pipe( + Effect.scoped, + Effect.timeoutOption(PI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Pi CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const trimmed = rawStdout.trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: "Pi returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi returned invalid structured output.", + cause, + }), + ), + }), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("PiAgentTextGeneration.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* runPiJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + 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("PiAgentTextGeneration.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* runPiJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("PiAgentTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runPiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("PiAgentTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runPiJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..0db9f2ffc57 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -265,4 +265,192 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); }); + + describe("makeDirectory", () => { + it.effect("creates directories relative to the workspace root with parents", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const result = yield* workspaceFileSystem.makeDirectory({ + cwd, + relativePath: "src/components/button", + }); + + expect(result).toEqual({ relativePath: "src/components/button" }); + const stat = yield* fileSystem.stat(path.join(cwd, "src", "components", "button")); + expect(stat.type).toBe("Directory"); + }), + ); + + it.effect("invalidates workspace entry search cache after creating directories", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + // The first list builds the cached index while the tree is empty. + const beforeCreate = yield* workspaceEntries.list({ cwd }); + expect( + beforeCreate.entries.some((entry) => entry.path === "src/components/button.ts"), + ).toBe(false); + + // The file exists before makeDirectory's refresh rescans, so the + // directory + file are only visible if makeDirectory invalidated the + // cached index (the raw write below never refreshes on its own). + yield* writeTextFile(cwd, "src/components/button.ts", "export {};\n"); + yield* workspaceFileSystem.makeDirectory({ cwd, relativePath: "src/components" }); + + const afterCreate = yield* workspaceEntries.list({ cwd }); + expect(afterCreate.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/components", kind: "directory" }), + expect.objectContaining({ path: "src/components/button.ts", kind: "file" }), + ]), + ); + }), + ); + + it.effect("is idempotent for an existing directory", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* workspaceFileSystem.makeDirectory({ cwd, relativePath: "src" }); + yield* workspaceFileSystem.makeDirectory({ cwd, relativePath: "src" }); + + const stat = yield* fileSystem.stat(path.join(cwd, "src")); + expect(stat.type).toBe("Directory"); + }), + ); + + it.effect("rejects paths outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const error = yield* workspaceFileSystem + .makeDirectory({ cwd, relativePath: "../escape-dir" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape-dir", + ); + + const escapedPath = path.resolve(cwd, "..", "escape-dir"); + const escapedStat = yield* fileSystem + .stat(escapedPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(escapedStat).toBeNull(); + }), + ); + }); + + describe("deleteFile", () => { + it.effect("deletes a file relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "notes.md", "delete me\n"); + + const result = yield* workspaceFileSystem.deleteFile({ cwd, relativePath: "notes.md" }); + + expect(result).toEqual({ relativePath: "notes.md" }); + const stat = yield* fileSystem + .stat(path.join(cwd, "notes.md")) + .pipe(Effect.orElseSucceed(() => null)); + expect(stat).toBeNull(); + }), + ); + + it.effect("deletes a directory recursively when requested", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/a.ts", "a\n"); + yield* writeTextFile(cwd, "src/nested/b.ts", "b\n"); + + const result = yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: "src", + recursive: true, + }); + + expect(result).toEqual({ relativePath: "src" }); + const stat = yield* fileSystem + .stat(path.join(cwd, "src")) + .pipe(Effect.orElseSucceed(() => null)); + expect(stat).toBeNull(); + }), + ); + + it.effect("rejects deleting a directory without recursive", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "src/a.ts", "a\n"); + + const error = yield* workspaceFileSystem + .deleteFile({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceDirectoryRequiresRecursiveError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "src", + }); + const stat = yield* fileSystem.stat(path.join(cwd, "src")); + expect(stat.type).toBe("Directory"); + }), + ); + + it.effect("invalidates workspace entry search cache after deletes", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "src/notes.md", "notes\n"); + + yield* workspaceFileSystem.deleteFile({ cwd, relativePath: "src/notes.md" }); + + const afterDelete = yield* workspaceEntries.list({ cwd }); + expect(afterDelete.entries.some((entry) => entry.path === "src/notes.md")).toBe(false); + }), + ); + + it.effect("rejects paths outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "keep.md", "keep\n"); + + const error = yield* workspaceFileSystem + .deleteFile({ cwd, relativePath: "../escape.md" }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + const escapedPath = path.resolve(cwd, "..", "escape.md"); + const escapedStat = yield* fileSystem + .stat(escapedPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(escapedStat).toBeNull(); + }), + ); + }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..5260f1fd964 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -10,6 +10,10 @@ import * as NodeFSP from "node:fs/promises"; import type { + ProjectDeleteFileInput, + ProjectDeleteFileResult, + ProjectMakeDirectoryInput, + ProjectMakeDirectoryResult, ProjectReadFileInput, ProjectReadFileResult, ProjectWriteFileInput, @@ -43,6 +47,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceDirectoryRequiresRecursiveError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace directory '${this.relativePath}' in '${this.workspaceRoot}' requires recursive deletion: ${this.resolvedPath}`; + } +} + export const WorkspaceFileSystemError = Schema.Union([ WorkspaceFileSystemOperationError, WorkspaceFilePathEscapeError, WorkspacePathNotFileError, WorkspaceBinaryFileError, + WorkspaceDirectoryRequiresRecursiveError, ]); export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; @@ -123,6 +142,27 @@ export class WorkspaceFileSystem extends Context.Service< ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError >; + /** + * Create a directory relative to the workspace root, creating missing + * parents as needed. Existing directories are left untouched. + */ + readonly makeDirectory: ( + input: ProjectMakeDirectoryInput, + ) => Effect.Effect< + ProjectMakeDirectoryResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; + /** + * Delete a file or directory relative to the workspace root. Directories + * require `recursive: true`; deleting one without it fails with + * `WorkspaceDirectoryRequiresRecursiveError`. + */ + readonly deleteFile: ( + input: ProjectDeleteFileInput, + ) => Effect.Effect< + ProjectDeleteFileResult, + WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError + >; } >()("t3/workspace/WorkspaceFileSystem") {} @@ -297,7 +337,78 @@ export const make = Effect.gen(function* () { return { relativePath: target.relativePath }; }); - return WorkspaceFileSystem.of({ readFile, writeFile }); + const makeDirectory: WorkspaceFileSystem["Service"]["makeDirectory"] = Effect.fn( + "WorkspaceFileSystem.makeDirectory", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(target.absolutePath, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "make-directory", + cause, + }), + ), + ); + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + const deleteFile: WorkspaceFileSystem["Service"]["deleteFile"] = Effect.fn( + "WorkspaceFileSystem.deleteFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const stat = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "stat", + cause, + }), + ), + ); + if (stat.type === "Directory" && !input.recursive) { + return yield* new WorkspaceDirectoryRequiresRecursiveError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + }); + } + + yield* fileSystem.remove(target.absolutePath, { recursive: input.recursive }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "delete-file", + cause, + }), + ), + ); + yield* workspaceEntries.refresh(input.cwd); + return { relativePath: target.relativePath }; + }); + + return WorkspaceFileSystem.of({ readFile, writeFile, makeDirectory, deleteFile }); }); export const layer = Layer.effect(WorkspaceFileSystem, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fc65602679a..88c21b732c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,8 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProjectMakeDirectoryError, + ProjectDeleteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -250,6 +252,11 @@ function projectFileFailureContext( return { failure: "path_not_file", resolvedPath: error.resolvedPath }; case "WorkspaceBinaryFileError": return { failure: "binary_file", resolvedPath: error.resolvedPath }; + case "WorkspaceDirectoryRequiresRecursiveError": + return { + failure: "directory_requires_recursive", + resolvedPath: error.resolvedPath, + }; default: return unexpectedCompatibilityError(error); } @@ -1687,6 +1694,38 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsMakeDirectory]: (input) => + observeRpcEffect( + WS_METHODS.projectsMakeDirectory, + workspaceFileSystem.makeDirectory(input).pipe( + Effect.mapError( + (cause) => + new ProjectMakeDirectoryError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsDeleteFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsDeleteFile, + workspaceFileSystem.deleteFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectDeleteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d9159688085..f288188b28c 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -8,6 +8,7 @@ import { import { useState } from "react"; import { toastManager } from "../components/ui/toast"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { relayEnvironmentDiscovery } from "../state/relay"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -63,7 +64,7 @@ export function useCloudLinkController() { ? { secondaryActionProps: { children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), + onClick: () => void writeTextToClipboard(traceId, "trace id"), }, } : undefined, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b..ff3eb3fffca 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -351,16 +351,14 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const handleCopy = useCallback((format: "markdown" | "csv") => { const table = containerRef.current?.querySelector("table"); - if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } + if (!table) return; const text = format === "markdown" ? serializeTableElementToMarkdown(table) : serializeTableElementToCsv(table); - void navigator.clipboard - .writeText(text) - .then(() => { + void writeTextToClipboard(text, "table").then( + (didCopy) => { + if (!didCopy) return; if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); } @@ -369,10 +367,11 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { setCopied(false); copiedTimerRef.current = null; }, 1200); - }) - .catch((cause) => { + }, + (cause) => { reportMarkdownActionFailure({ operation: "copy-table", format }, cause); - }); + }, + ); }, []); useEffect( @@ -555,12 +554,9 @@ function MarkdownCodeBlock({ const copyLabel = copied ? "Copied" : "Copy code"; const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { + void writeTextToClipboard(code, "code block").then( + (didCopy) => { + if (!didCopy) return; if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); } @@ -569,8 +565,8 @@ function MarkdownCodeBlock({ setCopied(false); copiedTimerRef.current = null; }, 1200); - }) - .catch((cause) => { + }, + (cause) => { reportMarkdownActionFailure( { operation: "copy-code-block", @@ -579,7 +575,8 @@ function MarkdownCodeBlock({ }, cause, ); - }); + }, + ); }, [code, fenceTitle, language]); useEffect( @@ -1112,19 +1109,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const handleCopy = useCallback( (value: string, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - void navigator.clipboard.writeText(value).then( - () => { + void writeTextToClipboard(value, title.toLowerCase()).then( + (didCopy) => { + if (!didCopy) return; toastManager.add({ type: "success", title: `${title} copied`, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c260c9e9118..caa78838871 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -117,11 +117,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, @@ -145,11 +140,6 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; -import { AgentsPanel } from "./AgentsPanel"; -import { - deriveAgentPanelModel, - foldSubagentActivities, -} from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -160,6 +150,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -290,6 +281,7 @@ import { waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; @@ -401,6 +393,11 @@ const PreviewPanel = lazy(() => ); const DiffPanel = lazy(() => import("./DiffPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const CreateFileDialog = lazy(() => + import("./files/CreateFileDialog").then((module) => ({ + default: module.CreateFileDialog, + })), +); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -1314,6 +1311,8 @@ function ChatViewContent(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + // Tracks whether the user explicitly dismissed the sidebar for the active turn. + const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); @@ -1985,41 +1984,31 @@ function ChatViewContent(props: ChatViewProps) { const updateFailed = serverUpdateState.status === "failed"; items.push({ id: `server-version:${serverUpdateEnvironmentId}`, - variant: updateFailed ? "error" : "default", - // In-flight and failed states carry their own status dot inside - // ServerUpdateProgress; only the idle offer needs an icon. - icon: - updateInProgress || updateFailed ? null : ( -