From 91249c83e9166a61deded2e48570a6a494db63d6 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Thu, 14 May 2026 04:04:52 -0400 Subject: [PATCH 01/11] Add Droid SDK provider WIP --- apps/server/package.json | 1 + .../src/provider/Drivers/DroidDriver.ts | 155 ++++++ .../src/provider/Layers/DroidAdapter.ts | 507 ++++++++++++++++++ .../src/provider/Layers/DroidProvider.ts | 172 ++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/builtInDrivers.ts | 3 + apps/web/src/components/Icons.tsx | 19 + .../components/KeybindingsToast.browser.tsx | 1 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 10 +- apps/web/src/session-logic.ts | 6 + bun.lock | 7 + packages/contracts/src/model.ts | 5 + packages/contracts/src/providerRuntime.ts | 2 + packages/contracts/src/settings.ts | 32 ++ 15 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DroidDriver.ts create mode 100644 apps/server/src/provider/Layers/DroidAdapter.ts create mode 100644 apps/server/src/provider/Layers/DroidProvider.ts diff --git a/apps/server/package.json b/apps/server/package.json index 8d813de5456..65d5ffd1ec4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@factory/droid-sdk": "^0.2.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts new file mode 100644 index 00000000000..4a57939442a --- /dev/null +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -0,0 +1,155 @@ +import { + DroidSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDroidAdapter } from "../Layers/DroidAdapter.ts"; +import { checkDroidProviderStatus, makePendingDroidProvider } from "../Layers/DroidProvider.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 { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; + +const decodeDroidSettings = Schema.decodeSync(DroidSettings); +const DRIVER_KIND = ProviderDriverKind.make("droid"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "droid", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type DroidDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient; + +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 }, + }); + +function makeUnsupportedTextGeneration(): TextGenerationShape { + const fail = (operation: TextGenerationError["operation"]) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Droid SDK text generation is not enabled in this WIP.", + }), + ); + return { + generateCommitMessage: () => fail("generateCommitMessage"), + generatePrContent: () => fail("generatePrContent"), + generateBranchName: () => fail("generateBranchName"), + generateThreadTitle: () => fail("generateThreadTitle"), + }; +} + +export const DroidDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Droid", + supportsMultipleInstances: true, + }, + configSchema: DroidSettings, + defaultConfig: (): DroidSettings => decodeDroidSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + 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 DroidSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeDroidAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + }); + const checkProvider = checkDroidProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingDroidProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Droid snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts new file mode 100644 index 00000000000..b9b55821a1e --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -0,0 +1,507 @@ +import { randomUUID } from "node:crypto"; +import { + AutonomyLevel, + createSession, + DroidInteractionMode, + DroidMessageType, + ReasoningEffort, + resumeSession, + ToolConfirmationOutcome, + ToolConfirmationType, + type DroidMessage, + type DroidSession, + type RequestPermissionRequestParams, +} from "@factory/droid-sdk"; +import { + ApprovalRequestId, + EventId, + ProviderDriverKind, + ProviderInstanceId, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, + type CanonicalRequestType, + type DroidSettings, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSessionStartInput, + type RuntimeContentStreamKind, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { + type ProviderAdapterError, + ProviderAdapterRequestError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("droid"); + +interface PendingPermission { + readonly requestType: CanonicalRequestType; + readonly resolve: (decision: ToolConfirmationOutcome) => void; +} + +interface DroidContext { + session: ProviderSession; + readonly droid: DroidSession; + readonly pendingPermissions: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeAbort: AbortController | undefined; + activeAssistantItems: Set; +} + +export interface DroidAdapterOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; +} + +const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); +const eventId = () => EventId.make(randomUUID()); + +function updateContextSession(context: DroidContext, patch: Partial) { + context.session = { + ...context.session, + ...patch, + updatedAt: nowIso(), + }; +} + +function toModelId(model: string | undefined): string | undefined { + return !model || model === "default" ? undefined : model; +} + +function toReasoningEffort(value: string | undefined): ReasoningEffort | undefined { + switch (value) { + case "low": + return ReasoningEffort.Low; + case "high": + return ReasoningEffort.High; + case "xhigh": + return ReasoningEffort.ExtraHigh; + case "medium": + return ReasoningEffort.Medium; + default: + return undefined; + } +} + +function toAutonomyLevel(input: ProviderSessionStartInput): AutonomyLevel { + switch (input.runtimeMode) { + case "approval-required": + return AutonomyLevel.Off; + case "auto-accept-edits": + return AutonomyLevel.Low; + case "full-access": + return AutonomyLevel.High; + } +} + +function toRequestType(params: RequestPermissionRequestParams): CanonicalRequestType { + const type = params.toolUses[0]?.confirmationType; + switch (type) { + case ToolConfirmationType.Execute: + return "command_execution_approval"; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "file_change_approval"; + case ToolConfirmationType.McpTool: + return "dynamic_tool_call"; + case ToolConfirmationType.AskUser: + return "tool_user_input"; + default: + return "unknown"; + } +} + +function permissionDetail(params: RequestPermissionRequestParams): string { + const first = params.toolUses[0]; + if (!first) return "Droid requested permission."; + const details = first.details; + switch (details.type) { + case ToolConfirmationType.Execute: + return details.fullCommand; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "filePath" in details ? details.filePath : "Droid requested a file change."; + case ToolConfirmationType.McpTool: + return details.toolName; + default: + return first.toolUse.name; + } +} + +function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome { + switch (decision) { + case "accept": + return ToolConfirmationOutcome.ProceedOnce; + case "acceptForSession": + return ToolConfirmationOutcome.ProceedAlways; + case "decline": + case "cancel": + return ToolConfirmationOutcome.Cancel; + } +} + +export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapterOptions) { + return Effect.gen(function* () { + const instanceId = options?.instanceId ?? ProviderInstanceId.make("droid"); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + const env = Object.fromEntries( + Object.entries({ ...process.env, ...options?.environment }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + yield* Effect.forEach( + contexts, + (context) => + Effect.tryPromise(() => { + context.activeAbort?.abort(); + return context.droid.close(); + }).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + yield* Queue.shutdown(runtimeEvents); + }), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitNow = (event: ProviderRuntimeEvent) => Effect.runPromise(emit(event)); + const eventBase = ( + context: DroidContext, + input?: { + turnId?: TurnId; + itemId?: string; + requestId?: string; + raw?: unknown; + }, + ) => ({ + eventId: eventId(), + provider: PROVIDER, + providerInstanceId: instanceId, + threadId: context.session.threadId, + createdAt: nowIso(), + ...(input?.turnId ? { turnId: input.turnId } : {}), + ...(input?.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input?.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input?.raw !== undefined + ? { raw: { source: "droid.sdk.message" as const, payload: input.raw } } + : {}), + }); + + type DroidAdapterShape = ProviderAdapterShape; + const startSession: DroidAdapterShape["startSession"] = Effect.fn("startDroidSession")( + function* (input) { + let contextRef: DroidContext | undefined; + const permissionHandler = (params: RequestPermissionRequestParams) => + new Promise((resolve) => { + const context = contextRef; + if (!context) { + resolve(ToolConfirmationOutcome.Cancel); + return; + } + const requestId = ApprovalRequestId.make(`droid-${randomUUID()}`); + const requestType = toRequestType(params); + context.pendingPermissions.set(requestId, { requestType, resolve }); + void emitNow({ + ...eventBase(context, { requestId, raw: params }), + raw: { source: "droid.sdk.permission", payload: params }, + type: "request.opened", + payload: { + requestType, + detail: permissionDetail(params), + args: params, + }, + }); + }); + const modelSelection = input.modelSelection; + const modelId = toModelId(modelSelection?.model); + const sdkOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + execPath: settings.binaryPath, + env, + permissionHandler, + }; + const reasoningEffort = toReasoningEffort( + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"), + ); + const droid = yield* Effect.tryPromise({ + try: () => + typeof input.resumeCursor === "string" + ? resumeSession(input.resumeCursor, sdkOptions) + : createSession({ + ...sdkOptions, + ...(modelId ? { modelId } : {}), + autonomyLevel: toAutonomyLevel(input), + interactionMode: DroidInteractionMode.Auto, + ...(reasoningEffort ? { reasoningEffort } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "createSession", + detail: cause instanceof Error ? cause.message : "Failed to start Droid session.", + cause, + }), + }); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + model: modelSelection?.model ?? "default", + threadId: input.threadId, + resumeCursor: droid.sessionId, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const context: DroidContext = { + session, + droid, + pendingPermissions: new Map(), + turns: [], + activeAbort: undefined, + activeAssistantItems: new Set(), + }; + contextRef = context; + sessions.set(input.threadId, context); + + yield* emit({ + ...eventBase(context), + type: "session.started", + payload: { message: "Droid SDK session started" }, + }); + yield* emit({ + ...eventBase(context), + type: "thread.started", + payload: { providerThreadId: droid.sessionId }, + }); + return session; + }, + ); + + const handleMessage = (context: DroidContext, turnId: TurnId, message: DroidMessage) => { + const base = (itemId?: string) => + eventBase(context, { turnId, raw: message, ...(itemId ? { itemId } : {}) }); + switch (message.type) { + case DroidMessageType.AssistantTextDelta: + case DroidMessageType.ThinkingTextDelta: { + const itemId = `${message.messageId}-${message.blockIndex}`; + const streamKind: RuntimeContentStreamKind = + message.type === DroidMessageType.AssistantTextDelta + ? "assistant_text" + : "reasoning_text"; + if (streamKind === "assistant_text") context.activeAssistantItems.add(itemId); + return emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind, delta: message.text }, + }); + } + case DroidMessageType.ToolUse: + return emitNow({ + ...base(message.toolUseId), + type: "item.started", + payload: { + itemType: "dynamic_tool_call", + title: message.toolName, + data: message.toolInput, + }, + }); + case DroidMessageType.ToolResult: + return emitNow({ + ...base(message.toolUseId), + type: "item.completed", + payload: { + itemType: "dynamic_tool_call", + title: message.toolName, + detail: typeof message.content === "string" ? message.content : undefined, + }, + }); + case DroidMessageType.Error: + return emitNow({ + ...base(), + type: "runtime.error", + payload: { message: message.message, class: "provider_error" }, + }); + default: + return Promise.resolve(); + } + }; + + const sendTurn: DroidAdapterShape["sendTurn"] = Effect.fn("sendDroidTurn")(function* (input) { + const context = sessions.get(input.threadId); + if (!context) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Unknown Droid thread: ${input.threadId}`, + }); + } + if ((input.attachments?.length ?? 0) > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Droid SDK attachment bridging is not enabled in this WIP.", + }); + } + const text = input.input?.trim(); + if (!text) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Droid turns require text input.", + }); + } + + const turnId = TurnId.make(`droid-turn-${randomUUID()}`); + const abort = new AbortController(); + context.activeAbort = abort; + context.activeAssistantItems = new Set(); + context.turns.push({ id: turnId, items: [] }); + updateContextSession(context, { + status: "running", + activeTurnId: turnId, + model: input.modelSelection?.model ?? context.session.model, + }); + + yield* emit({ + ...eventBase(context, { turnId }), + type: "turn.started", + payload: { model: context.session.model }, + }); + + yield* Effect.promise(async () => { + try { + if (input.interactionMode === "plan") { + await context.droid.enterSpecMode(); + } + const modelId = toModelId(input.modelSelection?.model); + const reasoningEffort = toReasoningEffort( + getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort"), + ); + if (modelId || reasoningEffort) { + await context.droid.updateSettings({ + ...(modelId ? { modelId } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + } + for await (const message of context.droid.stream(text, { abortSignal: abort.signal })) { + await handleMessage(context, turnId, message); + } + for (const itemId of context.activeAssistantItems) { + await emitNow({ + ...eventBase(context, { turnId, itemId }), + type: "item.completed", + payload: { itemType: "assistant_message" }, + }); + } + updateContextSession(context, { status: "ready", activeTurnId: undefined }); + await emitNow({ + ...eventBase(context, { turnId }), + type: "turn.completed", + payload: { state: "completed" }, + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Droid turn failed."; + updateContextSession(context, { + status: "error", + activeTurnId: undefined, + lastError: message, + }); + await emitNow({ + ...eventBase(context, { turnId }), + type: "runtime.error", + payload: { message, class: "provider_error" }, + }); + await emitNow({ + ...eventBase(context, { turnId }), + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + } + }).pipe(Effect.forkDetach); + + return { threadId: input.threadId, turnId, resumeCursor: context.droid.sessionId }; + }); + + const stopSession = (threadId: ThreadId) => + Effect.promise(async () => { + const context = sessions.get(threadId); + if (!context) return; + sessions.delete(threadId); + context.activeAbort?.abort(); + await context.droid.close(); + await emitNow({ + ...eventBase(context), + type: "session.exited", + payload: { reason: "Session stopped", recoverable: false, exitKind: "graceful" }, + }); + }); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn: (threadId) => + Effect.promise(async () => { + const context = sessions.get(threadId); + context?.activeAbort?.abort(); + await context?.droid.interrupt(); + }), + respondToRequest: (threadId, requestId, decision) => + Effect.gen(function* () { + const context = sessions.get(threadId); + const pending = context?.pendingPermissions.get(requestId); + if (!context || !pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: `Unknown pending Droid permission request: ${requestId}`, + }); + } + context.pendingPermissions.delete(requestId); + pending.resolve(toOutcome(decision)); + yield* emit({ + ...eventBase(context, { requestId }), + type: "request.resolved", + payload: { requestType: pending.requestType, decision }, + }); + }), + respondToUserInput: () => Effect.void, + stopSession, + listSessions: () => Effect.succeed([...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.succeed(sessions.has(threadId)), + readThread: (threadId) => + Effect.succeed({ threadId, turns: sessions.get(threadId)?.turns ?? [] }), + rollbackThread: (threadId) => + Effect.succeed({ threadId, turns: sessions.get(threadId)?.turns ?? [] }), + stopAll: () => + Effect.forEach([...sessions.keys()], stopSession, { + concurrency: "unbounded", + discard: true, + }), + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies DroidAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts new file mode 100644 index 00000000000..677e431b69c --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -0,0 +1,172 @@ +import { + type DroidSettings, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +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 { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + DEFAULT_TIMEOUT_MS, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("droid"); +const DROID_PRESENTATION = { + displayName: "Droid", + badgeLabel: "WIP", + showInteractionModeToggle: true, +} as const; + +const DROID_MODEL_CAPABILITIES = createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + }), + ], +}); + +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "default", + name: "Factory default", + shortName: "Default", + isCustom: false, + capabilities: DROID_MODEL_CAPABILITIES, + }, +]; + +export function makePendingDroidProvider( + settings: DroidSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + settings.customModels, + DROID_MODEL_CAPABILITIES, + ); + + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: settings.enabled, + version: null, + status: settings.enabled ? "warning" : "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Droid availability..." + : "Droid is disabled in T3 Code settings.", + }, + }); + }); +} + +export function checkDroidProviderStatus( + settings: DroidSettings, + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + settings.customModels, + DROID_MODEL_CAPABILITIES, + ); + + if (!settings.enabled) { + return yield* makePendingDroidProvider(settings); + } + + const command = ChildProcess.make(settings.binaryPath, ["--version"], { + env: environment, + shell: process.platform === "win32", + }); + const result = yield* spawnAndCollect(settings.binaryPath, command).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(result)) { + const cause = result.failure; + const message = cause instanceof Error ? cause.message : String(cause); + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause({ message }), + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isCommandMissingCause({ message }) + ? "Droid CLI (`droid`) is not installed or not on PATH." + : `Failed to execute Droid CLI health check: ${message}.`, + }, + }); + } + + if (Option.isNone(result.success)) { + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Timed out while checking Droid CLI availability.", + }, + }); + } + + const commandResult = result.success.value; + const detail = detailFromResult(commandResult); + const missing = detail ? isCommandMissingCause({ message: detail }) : false; + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: commandResult.code === 0 || !missing, + version: parseGenericCliVersion(commandResult.stdout || commandResult.stderr), + status: commandResult.code === 0 ? "ready" : "warning", + auth: { status: commandResult.code === 0 ? "unknown" : "unauthenticated" }, + ...(commandResult.code === 0 + ? {} + : { + message: missing + ? "Droid CLI (`droid`) is not installed or not on PATH." + : (detail ?? "Failed to check Droid CLI availability."), + }), + }, + }); + }); +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..52465fdc386 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1300,6 +1300,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "claudeAgent", "codex", "cursor", + "droid", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..3c5dce31913 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ 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 { DroidDriver, type DroidDriverEnv } from "./Drivers/DroidDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DroidDriverEnv | OpenCodeDriverEnv; /** @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray ( ); +export const DroidIcon: Icon = (props) => ( + + + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("droid")]: DroidIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..84a97b314d1 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DroidSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DroidIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -59,6 +60,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + icon: DroidIcon, + badgeLabel: "WIP", + settingsSchema: DroidSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..b3fc8a44dbe 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/bun.lock b/bun.lock index ffc4a5922bd..911aba5512a 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@factory/droid-sdk": "^0.2.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", @@ -474,6 +475,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "@factory/droid-sdk": ["@factory/droid-sdk@0.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "uuid": "^11.1.0", "zod": "^3.24.0" } }, "sha512-m8Srp98pTvu5jAZtZpX6/Ojut6KV3CiqUGh0MXBUcsuKzsDw8hODZEbPTocxP6MrT0rsoKpqCS9SRTt0m+9cqw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -2152,6 +2155,10 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@factory/droid-sdk/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "@factory/droid-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..2d965b94e16 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const DROID_DRIVER_KIND = ProviderDriverKind.make("droid"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [DROID_DRIVER_KIND]: "Droid", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..9ba336f78c8 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), + Schema.Literal("droid.sdk.message"), + Schema.Literal("droid.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..271181ee26f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,30 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DroidSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("droid").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Droid CLI used by the TypeScript SDK.", + providerSettingsForm: { placeholder: "droid", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DroidSettings = typeof DroidSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + droid: DroidSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -445,6 +470,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DroidSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + droid: Schema.optionalKey(DroidSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), From 33e12e42093878cd3aab4b90af78f228bd1a68da Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Thu, 14 May 2026 04:33:06 -0400 Subject: [PATCH 02/11] Implement Droid SDK runtime mapping --- .../src/provider/Drivers/DroidDriver.ts | 4 +- .../src/provider/Layers/DroidAdapter.test.ts | 237 +++++++++++++ .../src/provider/Layers/DroidAdapter.ts | 329 ++++++++++++++++-- 3 files changed, 545 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/provider/Layers/DroidAdapter.test.ts diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts index 4a57939442a..0fab3b30878 100644 --- a/apps/server/src/provider/Drivers/DroidDriver.ts +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -12,6 +12,7 @@ import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { ServerConfig } from "../../config.ts"; import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeDroidAdapter } from "../Layers/DroidAdapter.ts"; @@ -43,7 +44,8 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type DroidDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem - | HttpClient.HttpClient; + | HttpClient.HttpClient + | ServerConfig; const withInstanceIdentity = (input: { diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts new file mode 100644 index 00000000000..50247004b1d --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + AutonomyLevel, + DroidInteractionMode, + DroidMessageType, + ReasoningEffort, + ToolConfirmationOutcome, + ToolConfirmationType, + type CreateSessionOptions, + type DroidMessage, + type DroidSession, + type RequestPermissionRequestParams, +} from "@factory/droid-sdk"; +import { + ApprovalRequestId, + DroidSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +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 { ServerConfig } from "../../config.ts"; +import { makeDroidAdapter } from "./DroidAdapter.ts"; + +const settings = Schema.decodeSync(DroidSettings)({ + enabled: true, + binaryPath: "fake-droid", +}); +const threadId = ThreadId.make("thread-droid"); + +function fakeSession(input: { + readonly sessionId?: string; + readonly messages?: ReadonlyArray; + readonly onStream?: () => AsyncGenerator; +}): DroidSession { + return { + sessionId: input.sessionId ?? "droid-session-1", + initResult: { sessionId: input.sessionId ?? "droid-session-1" }, + stream: () => + input.onStream?.() ?? + (async function* () { + for (const message of input.messages ?? []) { + yield message; + } + })(), + send: async () => ({ + sessionId: input.sessionId ?? "droid-session-1", + text: "", + messages: [], + tokenUsage: null, + durationMs: 0, + turnCount: 1, + error: null, + structuredOutput: null, + success: true, + }), + interrupt: async () => undefined, + close: async () => undefined, + updateSettings: async () => ({}), + enterSpecMode: async () => ({}), + } as unknown as DroidSession; +} + +const testLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provideMerge(NodeServices.layer), +); + +it.effect("maps Droid SDK stream messages into canonical runtime events", () => + Effect.scoped( + Effect.gen(function* () { + let createOptions: CreateSessionOptions | undefined; + const adapter = yield* makeDroidAdapter(settings, { + instanceId: ProviderInstanceId.make("droid"), + sdk: { + createSession: async (options) => { + createOptions = options; + return fakeSession({ + messages: [ + { + type: DroidMessageType.AssistantTextDelta, + messageId: "m1", + blockIndex: 0, + text: "hi", + }, + { + type: DroidMessageType.ToolUse, + toolName: "Execute", + toolInput: {}, + toolUseId: "tool-1", + }, + { + type: DroidMessageType.ToolProgress, + toolName: "Execute", + toolUseId: "tool-1", + content: "running", + update: { type: "status", status: "running", text: "running" }, + }, + { + type: DroidMessageType.ToolResult, + toolName: "Execute", + toolUseId: "tool-1", + content: "done", + isError: false, + }, + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 10, + outputTokens: 4, + cacheCreationTokens: 2, + cacheReadTokens: 3, + thinkingTokens: 1, + }, + { type: DroidMessageType.SessionTitleUpdated, title: "Droid title" }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }); + }, + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(11), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: createModelSelection(ProviderInstanceId.make("droid"), "claude-sonnet", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + assert.equal(createOptions?.modelId, "claude-sonnet"); + assert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); + assert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); + assert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.started", + "item.updated", + "item.completed", + "thread.token-usage.updated", + "thread.metadata.updated", + "item.completed", + "turn.completed", + ], + ); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("routes Droid permission requests through adapter approvals", () => + Effect.scoped( + Effect.gen(function* () { + let permissionResult: string | undefined; + const permissionParams: RequestPermissionRequestParams = { + options: [ + { label: "Proceed once", value: ToolConfirmationOutcome.ProceedOnce }, + { label: "Cancel", value: ToolConfirmationOutcome.Cancel }, + ], + toolUses: [ + { + toolUse: { type: "tool_use", id: "tool-1", input: {}, name: "Execute" }, + confirmationType: ToolConfirmationType.Execute, + details: { + type: ToolConfirmationType.Execute, + fullCommand: "bun lint", + command: "bun", + }, + }, + ], + }; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async (options) => + fakeSession({ + onStream: async function* () { + const result = await options?.permissionHandler?.(permissionParams); + permissionResult = typeof result === "string" ? result : result?.selectedOption; + yield { type: DroidMessageType.TurnComplete, tokenUsage: null }; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "run lint" }); + const opened = yield* Fiber.join(openedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(opened._tag, "Some"); + const requestId = opened.value.requestId; + assert.ok(requestId); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(completed._tag, "Some"); + assert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); + }), + ).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index b9b55821a1e..e83da81a641 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -1,16 +1,23 @@ import { randomUUID } from "node:crypto"; import { AutonomyLevel, + type AskUserRequestParams, + type AskUserResult, + type Base64ImageSource, createSession, + type CreateSessionOptions, DroidInteractionMode, DroidMessageType, + type MessageOptions, ReasoningEffort, resumeSession, + type ResumeSessionOptions, ToolConfirmationOutcome, ToolConfirmationType, type DroidMessage, type DroidSession, type RequestPermissionRequestParams, + type TokenUsageUpdate, } from "@factory/droid-sdk"; import { ApprovalRequestId, @@ -27,14 +34,20 @@ import { type ProviderRuntimeEvent, type ProviderSession, type ProviderSessionStartInput, + type ProviderUserInputAnswers, type RuntimeContentStreamKind, + type ToolLifecycleItemType, + type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; +import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import { type ProviderAdapterError, ProviderAdapterRequestError, @@ -49,22 +62,46 @@ interface PendingPermission { readonly resolve: (decision: ToolConfirmationOutcome) => void; } +interface PendingUserInput { + readonly questions: ReadonlyArray; + readonly droidQuestions: AskUserRequestParams["questions"]; + readonly resolve: (result: AskUserResult) => void; +} + interface DroidContext { session: ProviderSession; readonly droid: DroidSession; readonly pendingPermissions: Map; + readonly pendingUserInputs: Map; readonly turns: Array<{ id: TurnId; items: Array }>; activeAbort: AbortController | undefined; - activeAssistantItems: Set; + activeAssistantItems: Map; + activeTokenUsage: TokenUsageUpdate | undefined; } export interface DroidAdapterOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + readonly sdk?: { + readonly createSession: (options?: CreateSessionOptions) => Promise; + readonly resumeSession: ( + sessionId: string, + options?: ResumeSessionOptions, + ) => Promise; + }; } const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); const eventId = () => EventId.make(randomUUID()); +const SUPPORTED_DROID_IMAGE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; +type SupportedDroidImageMimeType = (typeof SUPPORTED_DROID_IMAGE_MIME_TYPES)[number]; +const isSupportedDroidImageMimeType = (value: string): value is SupportedDroidImageMimeType => + (SUPPORTED_DROID_IMAGE_MIME_TYPES as ReadonlyArray).includes(value); function updateContextSession(context: DroidContext, patch: Partial) { context.session = { @@ -122,6 +159,24 @@ function toRequestType(params: RequestPermissionRequestParams): CanonicalRequest } } +function toToolItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("exec") || + normalized.includes("bash") || + normalized.includes("command") + ) { + return "command_execution"; + } + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("web")) return "web_search"; + if (normalized.includes("image")) return "image_view"; + return "dynamic_tool_call"; +} + function permissionDetail(params: RequestPermissionRequestParams): string { const first = params.toolUses[0]; if (!first) return "Droid requested permission."; @@ -140,6 +195,38 @@ function permissionDetail(params: RequestPermissionRequestParams): string { } } +function normalizeAskUserQuestions(params: AskUserRequestParams): ReadonlyArray { + return params.questions.map((question, index) => ({ + id: `question-${question.index ?? index}`, + header: question.topic || `Question ${index + 1}`, + question: question.question, + options: question.options.map((option) => ({ + label: option, + description: option, + })), + })); +} + +function answerString(value: unknown): string { + if (Array.isArray(value)) return value.map(answerString).join(", "); + return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value); +} + +function toAskUserResult( + questions: AskUserRequestParams["questions"], + answers: ProviderUserInputAnswers, +): AskUserResult { + return { + answers: questions.map((question, index) => ({ + index: question.index, + question: question.question, + answer: answerString( + answers[`question-${question.index ?? index}`] ?? answers[question.question], + ), + })), + }; +} + function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome { switch (decision) { case "accept": @@ -152,8 +239,27 @@ function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome } } +function toTokenUsageSnapshot(usage: TokenUsageUpdate) { + const inputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; + const outputTokens = usage.outputTokens + usage.thinkingTokens; + return { + usedTokens: inputTokens + outputTokens, + inputTokens, + cachedInputTokens: usage.cacheReadTokens, + outputTokens, + reasoningOutputTokens: usage.thinkingTokens, + lastInputTokens: inputTokens, + lastCachedInputTokens: usage.cacheReadTokens, + lastOutputTokens: outputTokens, + lastReasoningOutputTokens: usage.thinkingTokens, + }; +} + export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapterOptions) { return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const sdk = options?.sdk ?? { createSession, resumeSession }; const instanceId = options?.instanceId ?? ProviderInstanceId.make("droid"); const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); @@ -162,6 +268,8 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter (entry): entry is [string, string] => typeof entry[1] === "string", ), ); + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -182,7 +290,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); - const emitNow = (event: ProviderRuntimeEvent) => Effect.runPromise(emit(event)); + const emitNow = (event: ProviderRuntimeEvent) => runPromise(emit(event)); const eventBase = ( context: DroidContext, input?: { @@ -206,6 +314,52 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); type DroidAdapterShape = ProviderAdapterShape; + const resolveImages = Effect.fn("resolveDroidImages")(function* ( + input: NonNullable[0]["attachments"]>, + ) { + return yield* Effect.forEach( + input, + (attachment) => + Effect.gen(function* () { + if (!isSupportedDroidImageMimeType(attachment.mimeType)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Unsupported Droid image attachment type '${attachment.mimeType}'.`, + }); + } + 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: "base64", + data: Buffer.from(bytes).toString("base64"), + mediaType: attachment.mimeType, + } satisfies Base64ImageSource; + }), + { concurrency: 1 }, + ); + }); + const startSession: DroidAdapterShape["startSession"] = Effect.fn("startDroidSession")( function* (input) { let contextRef: DroidContext | undefined; @@ -230,6 +384,27 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }, }); }); + const askUserHandler = (params: AskUserRequestParams) => + new Promise((resolve) => { + const context = contextRef; + if (!context) { + resolve({ cancelled: true, answers: [] }); + return; + } + const requestId = ApprovalRequestId.make(`droid-question-${randomUUID()}`); + const questions = normalizeAskUserQuestions(params); + context.pendingUserInputs.set(requestId, { + questions, + droidQuestions: params.questions, + resolve, + }); + void emitNow({ + ...eventBase(context, { requestId, raw: params }), + raw: { source: "droid.sdk.permission", payload: params }, + type: "user-input.requested", + payload: { questions }, + }); + }); const modelSelection = input.modelSelection; const modelId = toModelId(modelSelection?.model); const sdkOptions = { @@ -237,6 +412,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter execPath: settings.binaryPath, env, permissionHandler, + askUserHandler, }; const reasoningEffort = toReasoningEffort( getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"), @@ -244,8 +420,8 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const droid = yield* Effect.tryPromise({ try: () => typeof input.resumeCursor === "string" - ? resumeSession(input.resumeCursor, sdkOptions) - : createSession({ + ? sdk.resumeSession(input.resumeCursor, sdkOptions) + : sdk.createSession({ ...sdkOptions, ...(modelId ? { modelId } : {}), autonomyLevel: toAutonomyLevel(input), @@ -276,9 +452,11 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter session, droid, pendingPermissions: new Map(), + pendingUserInputs: new Map(), turns: [], activeAbort: undefined, - activeAssistantItems: new Set(), + activeAssistantItems: new Map(), + activeTokenUsage: undefined, }; contextRef = context; sessions.set(input.threadId, context); @@ -308,7 +486,12 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter message.type === DroidMessageType.AssistantTextDelta ? "assistant_text" : "reasoning_text"; - if (streamKind === "assistant_text") context.activeAssistantItems.add(itemId); + if (streamKind === "assistant_text") { + context.activeAssistantItems.set( + itemId, + `${context.activeAssistantItems.get(itemId) ?? ""}${message.text}`, + ); + } return emitNow({ ...base(itemId), type: "content.delta", @@ -320,19 +503,91 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter ...base(message.toolUseId), type: "item.started", payload: { - itemType: "dynamic_tool_call", + itemType: toToolItemType(message.toolName), + status: "inProgress", title: message.toolName, data: message.toolInput, }, }); + case DroidMessageType.ToolProgress: + return emitNow({ + ...base(message.toolUseId), + type: "item.updated", + payload: { + itemType: toToolItemType(message.toolName), + status: "inProgress", + title: message.toolName, + detail: message.content, + data: message.update, + }, + }); case DroidMessageType.ToolResult: return emitNow({ ...base(message.toolUseId), type: "item.completed", payload: { - itemType: "dynamic_tool_call", + itemType: toToolItemType(message.toolName), + status: message.isError ? "failed" : "completed", title: message.toolName, - detail: typeof message.content === "string" ? message.content : undefined, + detail: + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content), + }, + }); + case DroidMessageType.WorkingStateChanged: + return emitNow({ + ...base(), + type: "session.state.changed", + payload: { + state: + message.state === "idle" + ? "ready" + : message.state.includes("waiting") + ? "waiting" + : "running", + detail: message, + }, + }); + case DroidMessageType.TokenUsageUpdate: + context.activeTokenUsage = message; + return emitNow({ + ...base(), + type: "thread.token-usage.updated", + payload: { usage: toTokenUsageSnapshot(message) }, + }); + case DroidMessageType.SessionTitleUpdated: + return emitNow({ + ...base(), + type: "thread.metadata.updated", + payload: { name: message.title }, + }); + case DroidMessageType.SettingsUpdated: + return emitNow({ + ...base(), + type: "session.configured", + payload: { config: message.settings }, + }); + case DroidMessageType.McpStatusChanged: + return emitNow({ + ...base(), + type: "mcp.status.updated", + payload: { status: message }, + }); + case DroidMessageType.McpAuthRequired: + return emitNow({ + ...base(), + type: "auth.status", + payload: { isAuthenticating: true, output: [message.message] }, + }); + case DroidMessageType.McpAuthCompleted: + return emitNow({ + ...base(), + type: "mcp.oauth.completed", + payload: { + success: message.outcome === "success", + name: message.serverName, + ...(message.outcome === "success" ? {} : { error: message.message }), }, }); case DroidMessageType.Error: @@ -341,6 +596,9 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter type: "runtime.error", payload: { message: message.message, class: "provider_error" }, }); + case DroidMessageType.TurnComplete: + if (message.tokenUsage) context.activeTokenUsage = message.tokenUsage; + return Promise.resolve(); default: return Promise.resolve(); } @@ -355,26 +613,21 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter issue: `Unknown Droid thread: ${input.threadId}`, }); } - if ((input.attachments?.length ?? 0) > 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Droid SDK attachment bridging is not enabled in this WIP.", - }); - } const text = input.input?.trim(); - if (!text) { + const images = yield* resolveImages(input.attachments ?? []); + if (!text && images.length === 0) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", - issue: "Droid turns require text input.", + issue: "Droid turns require text input or at least one attachment.", }); } const turnId = TurnId.make(`droid-turn-${randomUUID()}`); const abort = new AbortController(); context.activeAbort = abort; - context.activeAssistantItems = new Set(); + context.activeAssistantItems = new Map(); + context.activeTokenUsage = undefined; context.turns.push({ id: turnId, items: [] }); updateContextSession(context, { status: "running", @@ -403,21 +656,31 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter ...(reasoningEffort ? { reasoningEffort } : {}), }); } - for await (const message of context.droid.stream(text, { abortSignal: abort.signal })) { + const messageOptions: MessageOptions = { + abortSignal: abort.signal, + ...(images.length > 0 ? { images } : {}), + }; + for await (const message of context.droid.stream( + text || "Please respond to the attached image.", + messageOptions, + )) { await handleMessage(context, turnId, message); } - for (const itemId of context.activeAssistantItems) { + for (const [itemId, detail] of context.activeAssistantItems) { await emitNow({ ...eventBase(context, { turnId, itemId }), type: "item.completed", - payload: { itemType: "assistant_message" }, + payload: { itemType: "assistant_message", status: "completed", detail }, }); } updateContextSession(context, { status: "ready", activeTurnId: undefined }); await emitNow({ ...eventBase(context, { turnId }), type: "turn.completed", - payload: { state: "completed" }, + payload: { + state: "completed", + ...(context.activeTokenUsage ? { usage: context.activeTokenUsage } : {}), + }, }); } catch (cause) { const message = cause instanceof Error ? cause.message : "Droid turn failed."; @@ -486,7 +749,25 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter payload: { requestType: pending.requestType, decision }, }); }), - respondToUserInput: () => Effect.void, + respondToUserInput: (threadId, requestId, answers) => + Effect.gen(function* () { + const context = sessions.get(threadId); + const pending = context?.pendingUserInputs.get(requestId); + if (!context || !pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Unknown pending Droid user-input request: ${requestId}`, + }); + } + context.pendingUserInputs.delete(requestId); + pending.resolve(toAskUserResult(pending.droidQuestions, answers)); + yield* emit({ + ...eventBase(context, { requestId }), + type: "user-input.resolved", + payload: { answers }, + }); + }), stopSession, listSessions: () => Effect.succeed([...sessions.values()].map((context) => context.session)), hasSession: (threadId) => Effect.succeed(sessions.has(threadId)), From d1d3e20aaba298d7c4dbb4c23ece8c9d9efb1a11 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Thu, 14 May 2026 20:56:57 -0400 Subject: [PATCH 03/11] Address Droid model discovery and review comments --- .../src/provider/Layers/DroidAdapter.test.ts | 38 +++ .../src/provider/Layers/DroidAdapter.ts | 46 +++- .../src/provider/Layers/DroidProvider.test.ts | 59 +++++ .../src/provider/Layers/DroidProvider.ts | 228 +++++++++++++++--- apps/web/src/components/Icons.tsx | 16 +- .../components/chat/MessagesTimeline.test.tsx | 2 +- 6 files changed, 343 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/provider/Layers/DroidProvider.test.ts diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 50247004b1d..b8a6d7691b7 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -235,3 +235,41 @@ it.effect("routes Droid permission requests through adapter approvals", () => }), ).pipe(Effect.provide(testLayer)), ); + +it.effect("reads and rolls back Droid thread snapshots", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + }), + resumeSession: async () => fakeSession({}), + }, + }); + + const missing = yield* adapter + .readThread(ThreadId.make("missing-droid-thread")) + .pipe(Effect.exit); + assert.equal(missing._tag, "Failure"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "first" }); + yield* adapter.sendTurn({ threadId, input: "second" }); + + const before = yield* adapter.readThread(threadId); + assert.equal(before.turns.length, 2); + const after = yield* adapter.rollbackThread(threadId, 1); + assert.equal(after.turns.length, 1); + assert.equal(after.turns[0]?.id, before.turns[0]?.id); + + const invalid = yield* adapter.rollbackThread(threadId, 0).pipe(Effect.exit); + assert.equal(invalid._tag, "Failure"); + }), + ).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index e83da81a641..f06d41410df 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -51,6 +51,7 @@ import { ServerConfig } from "../../config.ts"; import { type ProviderAdapterError, ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -117,14 +118,24 @@ function toModelId(model: string | undefined): string | undefined { function toReasoningEffort(value: string | undefined): ReasoningEffort | undefined { switch (value) { + case "none": + return ReasoningEffort.None; + case "dynamic": + return ReasoningEffort.Dynamic; + case "off": + return ReasoningEffort.Off; + case "minimal": + return ReasoningEffort.Minimal; case "low": return ReasoningEffort.Low; + case "medium": + return ReasoningEffort.Medium; case "high": return ReasoningEffort.High; case "xhigh": return ReasoningEffort.ExtraHigh; - case "medium": - return ReasoningEffort.Medium; + case "max": + return ReasoningEffort.Max; default: return undefined; } @@ -312,6 +323,16 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter ? { raw: { source: "droid.sdk.message" as const, payload: input.raw } } : {}), }); + const requireSession = Effect.fn("requireDroidSession")(function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return context; + }); type DroidAdapterShape = ProviderAdapterShape; const resolveImages = Effect.fn("resolveDroidImages")(function* ( @@ -772,9 +793,24 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter listSessions: () => Effect.succeed([...sessions.values()].map((context) => context.session)), hasSession: (threadId) => Effect.succeed(sessions.has(threadId)), readThread: (threadId) => - Effect.succeed({ threadId, turns: sessions.get(threadId)?.turns ?? [] }), - rollbackThread: (threadId) => - Effect.succeed({ threadId, turns: sessions.get(threadId)?.turns ?? [] }), + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return { threadId, turns: context.turns }; + }), + rollbackThread: (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, context.turns.length - numTurns); + context.turns.splice(nextLength); + return { threadId, turns: context.turns }; + }), stopAll: () => Effect.forEach([...sessions.keys()], stopSession, { concurrency: "unbounded", diff --git a/apps/server/src/provider/Layers/DroidProvider.test.ts b/apps/server/src/provider/Layers/DroidProvider.test.ts new file mode 100644 index 00000000000..edc72a8a1df --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.test.ts @@ -0,0 +1,59 @@ +import { ModelProvider, ReasoningEffort, type AvailableModelConfig } from "@factory/droid-sdk"; +import { describe, expect, it } from "vitest"; + +import { buildDroidModelsFromSdkModels } from "./DroidProvider.ts"; + +const sdkModel = (model: AvailableModelConfig): AvailableModelConfig => model; + +describe("DroidProvider", () => { + it("maps Droid SDK built-in and custom models into provider models", () => { + const models = buildDroidModelsFromSdkModels([ + sdkModel({ + id: "glm-5.1", + modelId: "glm-5.1", + displayName: "Droid Core (GLM-5.1)", + shortDisplayName: "GLM-5.1", + modelProvider: ModelProvider.FACTORY, + supportedReasoningEfforts: [ReasoningEffort.Off, ReasoningEffort.High], + defaultReasoningEffort: ReasoningEffort.High, + isCustom: false, + }), + sdkModel({ + id: "custom:HomeLab-GLM-5.1-26", + modelId: "glm-5.1", + displayName: "HomeLab - GLM-5.1", + shortDisplayName: "HomeLab GLM", + modelProvider: ModelProvider.GENERIC_CHAT_COMPLETION_API, + supportedReasoningEfforts: [ReasoningEffort.None], + defaultReasoningEffort: ReasoningEffort.None, + isCustom: true, + }), + sdkModel({ + id: "custom:Proxy-GLM-5.1-27", + modelId: "glm-5.1", + displayName: "Proxy - GLM-5.1", + shortDisplayName: "Proxy GLM", + modelProvider: ModelProvider.GENERIC_CHAT_COMPLETION_API, + supportedReasoningEfforts: [ReasoningEffort.None], + defaultReasoningEffort: ReasoningEffort.None, + isCustom: true, + }), + ]); + + expect(models.map((model) => [model.slug, model.name, model.isCustom])).toEqual([ + ["glm-5.1", "Droid Core (GLM-5.1)", false], + ["custom:HomeLab-GLM-5.1-26", "HomeLab - GLM-5.1", true], + ["custom:Proxy-GLM-5.1-27", "Proxy - GLM-5.1", true], + ]); + expect(models[0]?.subProvider).toBe("Factory"); + expect(models[1]?.subProvider).toBe("Custom"); + expect(models[0]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + currentValue: "high", + options: [ + { id: "off", label: "Off" }, + { id: "high", label: "High", isDefault: true }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts index 677e431b69c..cfe3aad8875 100644 --- a/apps/server/src/provider/Layers/DroidProvider.ts +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -1,9 +1,19 @@ +import { + type AvailableModelConfig, + createSession, + type CreateSessionOptions, + type DroidSession, + ModelProvider, + ReasoningEffort, +} from "@factory/droid-sdk"; +import { tmpdir } from "node:os"; import { type DroidSettings, ProviderDriverKind, type ServerProviderModel, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; @@ -13,7 +23,6 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { buildSelectOptionDescriptor, buildServerProvider, - DEFAULT_TIMEOUT_MS, detailFromResult, isCommandMissingCause, parseGenericCliVersion, @@ -28,8 +37,22 @@ const DROID_PRESENTATION = { badgeLabel: "WIP", showInteractionModeToggle: true, } as const; +const DROID_CLI_TIMEOUT_MS = 10_000; +const DROID_MODEL_DISCOVERY_TIMEOUT_MS = 20_000; + +const REASONING_EFFORT_LABELS: Readonly> = { + [ReasoningEffort.None]: "None", + [ReasoningEffort.Dynamic]: "Dynamic", + [ReasoningEffort.Off]: "Off", + [ReasoningEffort.Minimal]: "Minimal", + [ReasoningEffort.Low]: "Low", + [ReasoningEffort.Medium]: "Medium", + [ReasoningEffort.High]: "High", + [ReasoningEffort.ExtraHigh]: "Extra High", + [ReasoningEffort.Max]: "Max", +}; -const DROID_MODEL_CAPABILITIES = createModelCapabilities({ +const DROID_FALLBACK_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [ buildSelectOptionDescriptor({ id: "reasoningEffort", @@ -44,27 +67,161 @@ const DROID_MODEL_CAPABILITIES = createModelCapabilities({ ], }); -const BUILT_IN_MODELS: ReadonlyArray = [ +const FALLBACK_MODELS: ReadonlyArray = [ { slug: "default", name: "Factory default", shortName: "Default", isCustom: false, - capabilities: DROID_MODEL_CAPABILITIES, + capabilities: DROID_FALLBACK_MODEL_CAPABILITIES, }, ]; +interface DroidProviderSdk { + readonly createSession: (options?: CreateSessionOptions) => Promise; +} + +interface DroidProviderStatusOptions { + readonly sdk?: DroidProviderSdk; +} + +class DroidModelDiscoveryError extends Data.TaggedError("DroidModelDiscoveryError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const defaultSdk: DroidProviderSdk = { createSession }; + +const modelProviderLabel = (provider: ModelProvider): string => { + switch (provider) { + case ModelProvider.ANTHROPIC: + return "Anthropic"; + case ModelProvider.OPENAI: + return "OpenAI"; + case ModelProvider.GENERIC_CHAT_COMPLETION_API: + return "Custom"; + case ModelProvider.FACTORY: + return "Factory"; + case ModelProvider.GOOGLE: + return "Google"; + case ModelProvider.XAI: + return "xAI"; + case ModelProvider.VOYAGE: + return "Voyage"; + } +}; + +const compactEnvironment = (environment: NodeJS.ProcessEnv): Record => { + const env: Record = {}; + for (const [key, value] of Object.entries(environment)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; +}; + +function droidModelCapabilities(model: AvailableModelConfig) { + const options = model.supportedReasoningEfforts.map((effort) => ({ + value: effort, + label: REASONING_EFFORT_LABELS[effort] ?? effort, + isDefault: effort === model.defaultReasoningEffort, + })); + return createModelCapabilities({ + optionDescriptors: + options.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options, + }), + ] + : [], + }); +} + +function droidAvailableModelToServerModel(model: AvailableModelConfig): ServerProviderModel | null { + const slug = model.isCustom + ? (nonEmpty(model.id) ?? nonEmpty(model.modelId)) + : (nonEmpty(model.modelId) ?? nonEmpty(model.id)); + if (!slug) return null; + const name = nonEmpty(model.displayName) ?? slug; + const shortName = nonEmpty(model.shortDisplayName); + return { + slug, + name, + ...(shortName && shortName !== name ? { shortName } : {}), + subProvider: modelProviderLabel(model.modelProvider), + isCustom: model.isCustom, + capabilities: droidModelCapabilities(model), + }; +} + +const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +export function buildDroidModelsFromSdkModels( + models: ReadonlyArray | undefined, +): ReadonlyArray { + const seen = new Set(); + const resolved: ServerProviderModel[] = []; + for (const model of models ?? []) { + const entry = droidAvailableModelToServerModel(model); + if (!entry || seen.has(entry.slug)) { + continue; + } + seen.add(entry.slug); + resolved.push(entry); + } + return resolved; +} + +const discoverDroidModels = ( + settings: DroidSettings, + environment: NodeJS.ProcessEnv, + options?: DroidProviderStatusOptions, +): Effect.Effect, DroidModelDiscoveryError> => + Effect.tryPromise({ + try: async () => { + let session: DroidSession | undefined; + try { + session = await (options?.sdk ?? defaultSdk).createSession({ + cwd: tmpdir(), + execPath: settings.binaryPath, + env: compactEnvironment(environment), + }); + return buildDroidModelsFromSdkModels(session.initResult.availableModels); + } finally { + await session?.close().catch(() => undefined); + } + }, + catch: (cause) => + new DroidModelDiscoveryError({ + message: cause instanceof Error ? cause.message : "Failed to discover Droid models.", + cause, + }), + }); + +const modelsWithSettingsFallback = ( + sdkModels: ReadonlyArray, + settings: DroidSettings, +): ReadonlyArray => + providerModelsFromSettings( + sdkModels.length > 0 ? sdkModels : FALLBACK_MODELS, + PROVIDER, + settings.customModels, + DROID_FALLBACK_MODEL_CAPABILITIES, + ); + export function makePendingDroidProvider( settings: DroidSettings, ): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - settings.customModels, - DROID_MODEL_CAPABILITIES, - ); + const models = modelsWithSettingsFallback([], settings); return buildServerProvider({ presentation: DROID_PRESENTATION, @@ -87,15 +244,11 @@ export function makePendingDroidProvider( export function checkDroidProviderStatus( settings: DroidSettings, environment: NodeJS.ProcessEnv, + options?: DroidProviderStatusOptions, ): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - settings.customModels, - DROID_MODEL_CAPABILITIES, - ); + const fallbackModels = modelsWithSettingsFallback([], settings); if (!settings.enabled) { return yield* makePendingDroidProvider(settings); @@ -106,7 +259,7 @@ export function checkDroidProviderStatus( shell: process.platform === "win32", }); const result = yield* spawnAndCollect(settings.binaryPath, command).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.timeoutOption(DROID_CLI_TIMEOUT_MS), Effect.result, ); @@ -117,7 +270,7 @@ export function checkDroidProviderStatus( presentation: DROID_PRESENTATION, enabled: true, checkedAt, - models, + models: fallbackModels, probe: { installed: !isCommandMissingCause({ message }), version: null, @@ -135,7 +288,7 @@ export function checkDroidProviderStatus( presentation: DROID_PRESENTATION, enabled: true, checkedAt, - models, + models: fallbackModels, probe: { installed: true, version: null, @@ -149,6 +302,25 @@ export function checkDroidProviderStatus( const commandResult = result.success.value; const detail = detailFromResult(commandResult); const missing = detail ? isCommandMissingCause({ message: detail }) : false; + const discoveredModels = + commandResult.code === 0 + ? yield* discoverDroidModels(settings, environment, options).pipe( + Effect.timeoutOption(DROID_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ) + : Result.succeed(Option.none>()); + const modelDiscoveryFailed = + commandResult.code === 0 && + (Result.isFailure(discoveredModels) || Option.isNone(discoveredModels.success)); + const discoveryMessage = Result.isFailure(discoveredModels) + ? discoveredModels.failure.message + : modelDiscoveryFailed + ? "Timed out while discovering Droid models." + : undefined; + const models = + Result.isSuccess(discoveredModels) && Option.isSome(discoveredModels.success) + ? modelsWithSettingsFallback(discoveredModels.success.value, settings) + : fallbackModels; return buildServerProvider({ presentation: DROID_PRESENTATION, enabled: true, @@ -157,15 +329,17 @@ export function checkDroidProviderStatus( probe: { installed: commandResult.code === 0 || !missing, version: parseGenericCliVersion(commandResult.stdout || commandResult.stderr), - status: commandResult.code === 0 ? "ready" : "warning", + status: commandResult.code === 0 && !modelDiscoveryFailed ? "ready" : "warning", auth: { status: commandResult.code === 0 ? "unknown" : "unauthenticated" }, - ...(commandResult.code === 0 - ? {} - : { - message: missing - ? "Droid CLI (`droid`) is not installed or not on PATH." - : (detail ?? "Failed to check Droid CLI availability."), - }), + ...(commandResult.code === 0 && discoveryMessage + ? { message: `Droid model discovery failed: ${discoveryMessage}` } + : commandResult.code === 0 + ? {} + : { + message: missing + ? "Droid CLI (`droid`) is not installed or not on PATH." + : (detail ?? "Failed to check Droid CLI availability."), + }), }, }); }); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index bc0ecb5e641..597fd8e2029 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -652,20 +652,10 @@ export const OpenCodeIcon: Icon = (props) => ( ); export const DroidIcon: Icon = (props) => ( - + - - ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 12103194870..d3b74a4693c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -131,7 +131,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-fade="true"'); expect(markup).toContain('data-user-message-footer="true"'); - }); + }, 10_000); it("does not render collapse controls for short user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); From c72f19b571aa841639d76febd8f9bdc406ebdb63 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Fri, 15 May 2026 17:45:13 -0400 Subject: [PATCH 04/11] Fix Droid streaming and access modes --- .../provider/Layers/CodexSessionRuntime.ts | 2 + .../src/provider/Layers/DroidAdapter.test.ts | 213 +++++++++++++++++- .../src/provider/Layers/DroidAdapter.ts | 81 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 39 +--- .../CompactComposerControlsMenu.browser.tsx | 2 + .../chat/CompactComposerControlsMenu.tsx | 15 +- .../chat/runtimeModePresentation.ts | 80 +++++++ packages/contracts/src/orchestration.ts | 1 + 8 files changed, 391 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/components/chat/runtimeModePresentation.ts diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 7f71ef46b2c..e4c610f7e42 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -269,6 +269,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { sandbox: "read-only", }; case "auto-accept-edits": + case "medium-access": return { approvalPolicy: "on-request", sandbox: "workspace-write", @@ -307,6 +308,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "medium-access": return { type: "workspaceWrite", }; diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index b8a6d7691b7..100541bf3f8 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -40,6 +40,8 @@ function fakeSession(input: { readonly sessionId?: string; readonly messages?: ReadonlyArray; readonly onStream?: () => AsyncGenerator; + readonly onEnterSpecMode?: (params: unknown) => void; + readonly onUpdateSettings?: (params: unknown) => void; }): DroidSession { return { sessionId: input.sessionId ?? "droid-session-1", @@ -64,8 +66,14 @@ function fakeSession(input: { }), interrupt: async () => undefined, close: async () => undefined, - updateSettings: async () => ({}), - enterSpecMode: async () => ({}), + updateSettings: async (params: unknown) => { + input.onUpdateSettings?.(params); + return {}; + }, + enterSpecMode: async (params: unknown) => { + input.onEnterSpecMode?.(params); + return {}; + }, } as unknown as DroidSession; } @@ -169,6 +177,207 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => ).pipe(Effect.provide(testLayer)), ); +it.effect("maps Droid medium access to medium autonomy", () => + Effect.scoped( + Effect.gen(function* () { + let createOptions: CreateSessionOptions | undefined; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async (options) => { + createOptions = options; + return fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + }); + }, + resumeSession: async () => fakeSession({}), + }, + }); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "medium-access", + }); + + assert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("uses final Droid create_message content when deltas are absent", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-final", + role: "assistant", + content: [ + { type: "thinking", signature: "test-signature", thinking: "final thought" }, + { type: "text", text: "final text" }, + ], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(6), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), + [ + { streamKind: "reasoning_text", delta: "final thought" }, + { streamKind: "assistant_text", delta: "final text" }, + ], + ); + const completed = events.find((event) => event.type === "item.completed"); + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.itemType, "assistant_message"); + assert.equal(completed.payload.detail, "final text"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("does not duplicate Droid final create_message text after streaming deltas", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.AssistantTextDelta, + messageId: "assistant-streamed", + blockIndex: 0, + text: "stre", + }, + { + type: DroidMessageType.AssistantTextDelta, + messageId: "assistant-streamed", + blockIndex: 0, + text: "am", + }, + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-streamed", + role: "assistant", + content: [{ type: "text", text: "stream" }], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(7), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : undefined)), + ["stre", "am"], + ); + const completed = events.find((event) => event.type === "item.completed"); + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.detail, "stream"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("passes custom model reasoning into Droid spec mode", () => + Effect.scoped( + Effect.gen(function* () { + let enterSpecModeParams: unknown; + let updateSettingsParams: unknown; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + onEnterSpecMode: (params) => { + enterSpecModeParams = params; + }, + onUpdateSettings: (params) => { + updateSettingsParams = params; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "plan", + interactionMode: "plan", + modelSelection: createModelSelection( + ProviderInstanceId.make("droid"), + "custom:Direct-GPT-5.5-xhigh-27", + [{ id: "reasoningEffort", value: "xhigh" }], + ), + }); + + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(completed._tag, "Some"); + assert.deepEqual(enterSpecModeParams, { + specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", + specModeReasoningEffort: ReasoningEffort.ExtraHigh, + }); + assert.deepEqual(updateSettingsParams, { + modelId: "custom:Direct-GPT-5.5-xhigh-27", + reasoningEffort: ReasoningEffort.ExtraHigh, + specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", + specModeReasoningEffort: ReasoningEffort.ExtraHigh, + }); + }), + ).pipe(Effect.provide(testLayer)), +); + it.effect("routes Droid permission requests through adapter approvals", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index f06d41410df..fc176dbeb14 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -4,6 +4,7 @@ import { type AskUserRequestParams, type AskUserResult, type Base64ImageSource, + type ContentBlock, createSession, type CreateSessionOptions, DroidInteractionMode, @@ -77,6 +78,7 @@ interface DroidContext { readonly turns: Array<{ id: TurnId; items: Array }>; activeAbort: AbortController | undefined; activeAssistantItems: Map; + activeCompletedAssistantItems: Set; activeTokenUsage: TokenUsageUpdate | undefined; } @@ -147,11 +149,19 @@ function toAutonomyLevel(input: ProviderSessionStartInput): AutonomyLevel { return AutonomyLevel.Off; case "auto-accept-edits": return AutonomyLevel.Low; + case "medium-access": + return AutonomyLevel.Medium; case "full-access": return AutonomyLevel.High; } } +function contentBlockText(block: ContentBlock): string { + if (block.type === "text") return block.text; + if (block.type === "thinking") return block.thinking; + return ""; +} + function toRequestType(params: RequestPermissionRequestParams): CanonicalRequestType { const type = params.toolUses[0]?.confirmationType; switch (type) { @@ -477,6 +487,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter turns: [], activeAbort: undefined, activeAssistantItems: new Map(), + activeCompletedAssistantItems: new Set(), activeTokenUsage: undefined, }; contextRef = context; @@ -496,7 +507,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }, ); - const handleMessage = (context: DroidContext, turnId: TurnId, message: DroidMessage) => { + const handleMessage = async (context: DroidContext, turnId: TurnId, message: DroidMessage) => { const base = (itemId?: string) => eventBase(context, { turnId, raw: message, ...(itemId ? { itemId } : {}) }); switch (message.type) { @@ -519,6 +530,57 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter payload: { streamKind, delta: message.text }, }); } + case DroidMessageType.CreateMessage: { + if (message.role !== "assistant") { + return; + } + for (const [index, block] of message.content.entries()) { + const text = contentBlockText(block); + if (text.length === 0) { + continue; + } + const itemId = block.id ?? `${message.messageId}-${index}`; + if (block.type === "text") { + const previousText = context.activeAssistantItems.get(itemId) ?? ""; + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta.length > 0) { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "assistant_text", delta }, + }); + } + context.activeAssistantItems.set(itemId, text); + continue; + } + if (block.type === "thinking") { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: text }, + }); + } + } + + const firstTextIndex = message.content.findIndex((block) => block.type === "text"); + const firstTextBlock = message.content[firstTextIndex]; + const completedItemId = + firstTextBlock?.id ?? + (firstTextIndex >= 0 ? `${message.messageId}-${firstTextIndex}` : message.messageId); + if (!context.activeCompletedAssistantItems.has(completedItemId)) { + context.activeCompletedAssistantItems.add(completedItemId); + return emitNow({ + ...base(completedItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + ...(firstTextBlock ? { detail: contentBlockText(firstTextBlock) } : {}), + }, + }); + } + return; + } case DroidMessageType.ToolUse: return emitNow({ ...base(message.toolUseId), @@ -648,6 +710,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const abort = new AbortController(); context.activeAbort = abort; context.activeAssistantItems = new Map(); + context.activeCompletedAssistantItems = new Set(); context.activeTokenUsage = undefined; context.turns.push({ id: turnId, items: [] }); updateContextSession(context, { @@ -664,17 +727,24 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter yield* Effect.promise(async () => { try { - if (input.interactionMode === "plan") { - await context.droid.enterSpecMode(); - } const modelId = toModelId(input.modelSelection?.model); const reasoningEffort = toReasoningEffort( getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort"), ); + if (input.interactionMode === "plan") { + await context.droid.enterSpecMode({ + ...(modelId ? { specModeModelId: modelId } : {}), + ...(reasoningEffort ? { specModeReasoningEffort: reasoningEffort } : {}), + }); + } if (modelId || reasoningEffort) { await context.droid.updateSettings({ ...(modelId ? { modelId } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.interactionMode === "plan" && modelId ? { specModeModelId: modelId } : {}), + ...(input.interactionMode === "plan" && reasoningEffort + ? { specModeReasoningEffort: reasoningEffort } + : {}), }); } const messageOptions: MessageOptions = { @@ -688,6 +758,9 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter await handleMessage(context, turnId, message); } for (const [itemId, detail] of context.activeAssistantItems) { + if (context.activeCompletedAssistantItems.has(itemId)) { + continue; + } await emitNow({ ...eventBase(context, { turnId, itemId }), type: "item.completed", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 96fc34c1013..951d3b4751d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -84,16 +84,7 @@ import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { - BotIcon, - CircleAlertIcon, - ListTodoIcon, - type LucideIcon, - LockIcon, - LockOpenIcon, - PenLineIcon, - XIcon, -} from "lucide-react"; +import { BotIcon, CircleAlertIcon, ListTodoIcon, XIcon } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { @@ -111,31 +102,10 @@ import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const runtimeModeConfig: Record< - RuntimeMode, - { label: string; description: string; icon: LucideIcon } -> = { - "approval-required": { - label: "Supervised", - description: "Ask before commands and file changes.", - icon: LockIcon, - }, - "auto-accept-edits": { - label: "Auto-accept edits", - description: "Auto-approve edits, ask before other actions.", - icon: PenLineIcon, - }, - "full-access": { - label: "Full access", - description: "Allow commands and edits without prompts.", - icon: LockOpenIcon, - }, -}; - -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_FLOATING_LAYER_SELECTOR = [ @@ -181,6 +151,7 @@ function isInsideComposerFloatingLayer(element: Element): boolean { const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; + provider: ProviderDriverKind; runtimeMode: RuntimeMode; showPlanToggle: boolean; planSidebarLabel: string; @@ -189,6 +160,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -2343,6 +2316,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode={interactionMode} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + provider={selectedProvider} runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} @@ -2361,6 +2335,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { interactionMode="default" planSidebarLabel="Plan" planSidebarOpen={false} + provider={ProviderDriverKind.make("claudeAgent")} runtimeMode="approval-required" showInteractionModeToggle={false} onToggleInteractionMode={vi.fn()} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..4348378d67a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,4 +1,4 @@ -import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; +import type { ProviderDriverKind, ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; @@ -11,12 +11,14 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; interactionMode: ProviderInteractionMode; planSidebarLabel: string; planSidebarOpen: boolean; + provider: ProviderDriverKind; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; @@ -24,6 +26,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); + return ( - Supervised - Auto-accept edits - Full access + {runtimeModeOptions.map((mode) => ( + + {runtimeModeConfig[mode].label} + + ))} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/runtimeModePresentation.ts b/apps/web/src/components/chat/runtimeModePresentation.ts new file mode 100644 index 00000000000..2b1f4926ea5 --- /dev/null +++ b/apps/web/src/components/chat/runtimeModePresentation.ts @@ -0,0 +1,80 @@ +import { ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; +import { LockIcon, LockOpenIcon, type LucideIcon, PenLineIcon, ShieldIcon } from "lucide-react"; + +export interface RuntimeModePresentation { + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; +} + +const BASE_RUNTIME_MODE_CONFIG: Record = { + "approval-required": { + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + icon: PenLineIcon, + }, + "medium-access": { + label: "Medium access", + description: "Allow reversible commands, ask before riskier actions.", + icon: ShieldIcon, + }, + "full-access": { + label: "Full access", + description: "Allow commands and edits without prompts.", + icon: LockOpenIcon, + }, +}; + +const DROID_RUNTIME_MODE_CONFIG: Record = { + "approval-required": { + label: "Off", + description: "Droid asks before every action.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Low", + description: "Allow file edits and read-only commands.", + icon: PenLineIcon, + }, + "medium-access": { + label: "Medium", + description: "Allow reversible commands.", + icon: ShieldIcon, + }, + "full-access": { + label: "High", + description: "Allow all Droid actions without prompts.", + icon: LockOpenIcon, + }, +}; + +const BASE_RUNTIME_MODE_OPTIONS: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "full-access", +]; +const DROID_RUNTIME_MODE_OPTIONS: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "medium-access", + "full-access", +]; + +export function getRuntimeModeConfig( + provider: ProviderDriverKind, +): Record { + return provider === ProviderDriverKind.make("droid") + ? DROID_RUNTIME_MODE_CONFIG + : BASE_RUNTIME_MODE_CONFIG; +} + +export function getRuntimeModeOptions(provider: ProviderDriverKind): ReadonlyArray { + return provider === ProviderDriverKind.make("droid") + ? DROID_RUNTIME_MODE_OPTIONS + : BASE_RUNTIME_MODE_OPTIONS; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 401928171c8..2e1a3407833 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -117,6 +117,7 @@ export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals([ "approval-required", "auto-accept-edits", + "medium-access", "full-access", ]); export type RuntimeMode = typeof RuntimeMode.Type; From b1dabf81736e241ed02d5174388d6ed2b2c6373d Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Fri, 15 May 2026 18:24:13 -0400 Subject: [PATCH 05/11] Harden Droid session stop cleanup --- .../src/provider/Layers/DroidAdapter.test.ts | 50 ++++++++++++++++++- .../src/provider/Layers/DroidAdapter.ts | 6 +-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 100541bf3f8..8a1b40b3f2a 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -40,6 +40,7 @@ function fakeSession(input: { readonly sessionId?: string; readonly messages?: ReadonlyArray; readonly onStream?: () => AsyncGenerator; + readonly onClose?: () => Promise; readonly onEnterSpecMode?: (params: unknown) => void; readonly onUpdateSettings?: (params: unknown) => void; }): DroidSession { @@ -65,7 +66,7 @@ function fakeSession(input: { success: true, }), interrupt: async () => undefined, - close: async () => undefined, + close: input.onClose ?? (async () => undefined), updateSettings: async (params: unknown) => { input.onUpdateSettings?.(params); return {}; @@ -445,6 +446,53 @@ it.effect("routes Droid permission requests through adapter approvals", () => ).pipe(Effect.provide(testLayer)), ); +it.effect("continues stopping Droid sessions when one close fails", () => + Effect.scoped( + Effect.gen(function* () { + const closedSessionIds: string[] = []; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => fakeSession({}), + resumeSession: async (sessionId) => + fakeSession({ + sessionId, + onClose: async () => { + closedSessionIds.push(sessionId); + if (sessionId === "droid-session-fails-close") { + throw new Error("close failed"); + } + }, + }), + }, + }); + + const firstThreadId = ThreadId.make("thread-droid-close-1"); + const secondThreadId = ThreadId.make("thread-droid-close-2"); + yield* adapter.startSession({ + threadId: firstThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + resumeCursor: "droid-session-fails-close", + }); + yield* adapter.startSession({ + threadId: secondThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + resumeCursor: "droid-session-closes", + }); + + yield* adapter.stopAll(); + + assert.deepEqual(closedSessionIds.toSorted(), [ + "droid-session-closes", + "droid-session-fails-close", + ]); + const sessions = yield* adapter.listSessions(); + assert.deepEqual(sessions, []); + }), + ).pipe(Effect.provide(testLayer)), +); + it.effect("reads and rolls back Droid thread snapshots", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index fc176dbeb14..fa02caed797 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -800,13 +800,13 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); const stopSession = (threadId: ThreadId) => - Effect.promise(async () => { + Effect.gen(function* () { const context = sessions.get(threadId); if (!context) return; sessions.delete(threadId); context.activeAbort?.abort(); - await context.droid.close(); - await emitNow({ + yield* Effect.tryPromise(() => context.droid.close()).pipe(Effect.ignore); + yield* emit({ ...eventBase(context), type: "session.exited", payload: { reason: "Session stopped", recoverable: false, exitKind: "graceful" }, From af7ffb0a4edb4190ce1c70440b3a18e14efdbcef Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Fri, 15 May 2026 19:34:30 -0400 Subject: [PATCH 06/11] Split Droid adapter responsibilities --- .../src/provider/Layers/DroidAdapter.ts | 552 ++---------------- .../src/provider/droid/DroidAdapterTypes.ts | 60 ++ .../provider/droid/DroidAttachmentResolver.ts | 72 +++ .../src/provider/droid/DroidRuntimeEvents.ts | 238 ++++++++ .../src/provider/droid/DroidSdkMappings.ts | 186 ++++++ 5 files changed, 600 insertions(+), 508 deletions(-) create mode 100644 apps/server/src/provider/droid/DroidAdapterTypes.ts create mode 100644 apps/server/src/provider/droid/DroidAttachmentResolver.ts create mode 100644 apps/server/src/provider/droid/DroidRuntimeEvents.ts create mode 100644 apps/server/src/provider/droid/DroidSdkMappings.ts diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index fa02caed797..879e25fa8c5 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -1,280 +1,60 @@ import { randomUUID } from "node:crypto"; import { - AutonomyLevel, type AskUserRequestParams, type AskUserResult, - type Base64ImageSource, - type ContentBlock, createSession, - type CreateSessionOptions, - DroidInteractionMode, - DroidMessageType, type MessageOptions, - ReasoningEffort, resumeSession, - type ResumeSessionOptions, ToolConfirmationOutcome, - ToolConfirmationType, - type DroidMessage, - type DroidSession, type RequestPermissionRequestParams, - type TokenUsageUpdate, } from "@factory/droid-sdk"; import { ApprovalRequestId, - EventId, - ProviderDriverKind, ProviderInstanceId, - RuntimeItemId, - RuntimeRequestId, ThreadId, TurnId, - type CanonicalRequestType, type DroidSettings, - type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, - type ProviderSessionStartInput, - type ProviderUserInputAnswers, - type RuntimeContentStreamKind, - type ToolLifecycleItemType, - type UserInputQuestion, } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; -import * as DateTime from "effect/DateTime"; import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { - type ProviderAdapterError, ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; - -const PROVIDER = ProviderDriverKind.make("droid"); - -interface PendingPermission { - readonly requestType: CanonicalRequestType; - readonly resolve: (decision: ToolConfirmationOutcome) => void; -} - -interface PendingUserInput { - readonly questions: ReadonlyArray; - readonly droidQuestions: AskUserRequestParams["questions"]; - readonly resolve: (result: AskUserResult) => void; -} - -interface DroidContext { - session: ProviderSession; - readonly droid: DroidSession; - readonly pendingPermissions: Map; - readonly pendingUserInputs: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - activeAbort: AbortController | undefined; - activeAssistantItems: Map; - activeCompletedAssistantItems: Set; - activeTokenUsage: TokenUsageUpdate | undefined; -} - -export interface DroidAdapterOptions { - readonly instanceId?: ProviderInstanceId; - readonly environment?: NodeJS.ProcessEnv; - readonly sdk?: { - readonly createSession: (options?: CreateSessionOptions) => Promise; - readonly resumeSession: ( - sessionId: string, - options?: ResumeSessionOptions, - ) => Promise; - }; -} - -const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); -const eventId = () => EventId.make(randomUUID()); -const SUPPORTED_DROID_IMAGE_MIME_TYPES = [ - "image/gif", - "image/jpeg", - "image/png", - "image/webp", -] as const; -type SupportedDroidImageMimeType = (typeof SUPPORTED_DROID_IMAGE_MIME_TYPES)[number]; -const isSupportedDroidImageMimeType = (value: string): value is SupportedDroidImageMimeType => - (SUPPORTED_DROID_IMAGE_MIME_TYPES as ReadonlyArray).includes(value); - -function updateContextSession(context: DroidContext, patch: Partial) { - context.session = { - ...context.session, - ...patch, - updatedAt: nowIso(), - }; -} - -function toModelId(model: string | undefined): string | undefined { - return !model || model === "default" ? undefined : model; -} - -function toReasoningEffort(value: string | undefined): ReasoningEffort | undefined { - switch (value) { - case "none": - return ReasoningEffort.None; - case "dynamic": - return ReasoningEffort.Dynamic; - case "off": - return ReasoningEffort.Off; - case "minimal": - return ReasoningEffort.Minimal; - case "low": - return ReasoningEffort.Low; - case "medium": - return ReasoningEffort.Medium; - case "high": - return ReasoningEffort.High; - case "xhigh": - return ReasoningEffort.ExtraHigh; - case "max": - return ReasoningEffort.Max; - default: - return undefined; - } -} - -function toAutonomyLevel(input: ProviderSessionStartInput): AutonomyLevel { - switch (input.runtimeMode) { - case "approval-required": - return AutonomyLevel.Off; - case "auto-accept-edits": - return AutonomyLevel.Low; - case "medium-access": - return AutonomyLevel.Medium; - case "full-access": - return AutonomyLevel.High; - } -} - -function contentBlockText(block: ContentBlock): string { - if (block.type === "text") return block.text; - if (block.type === "thinking") return block.thinking; - return ""; -} - -function toRequestType(params: RequestPermissionRequestParams): CanonicalRequestType { - const type = params.toolUses[0]?.confirmationType; - switch (type) { - case ToolConfirmationType.Execute: - return "command_execution_approval"; - case ToolConfirmationType.Edit: - case ToolConfirmationType.Create: - case ToolConfirmationType.ApplyPatch: - return "file_change_approval"; - case ToolConfirmationType.McpTool: - return "dynamic_tool_call"; - case ToolConfirmationType.AskUser: - return "tool_user_input"; - default: - return "unknown"; - } -} - -function toToolItemType(toolName: string): ToolLifecycleItemType { - const normalized = toolName.toLowerCase(); - if ( - normalized.includes("exec") || - normalized.includes("bash") || - normalized.includes("command") - ) { - return "command_execution"; - } - if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { - return "file_change"; - } - if (normalized.includes("mcp")) return "mcp_tool_call"; - if (normalized.includes("web")) return "web_search"; - if (normalized.includes("image")) return "image_view"; - return "dynamic_tool_call"; -} - -function permissionDetail(params: RequestPermissionRequestParams): string { - const first = params.toolUses[0]; - if (!first) return "Droid requested permission."; - const details = first.details; - switch (details.type) { - case ToolConfirmationType.Execute: - return details.fullCommand; - case ToolConfirmationType.Edit: - case ToolConfirmationType.Create: - case ToolConfirmationType.ApplyPatch: - return "filePath" in details ? details.filePath : "Droid requested a file change."; - case ToolConfirmationType.McpTool: - return details.toolName; - default: - return first.toolUse.name; - } -} - -function normalizeAskUserQuestions(params: AskUserRequestParams): ReadonlyArray { - return params.questions.map((question, index) => ({ - id: `question-${question.index ?? index}`, - header: question.topic || `Question ${index + 1}`, - question: question.question, - options: question.options.map((option) => ({ - label: option, - description: option, - })), - })); -} - -function answerString(value: unknown): string { - if (Array.isArray(value)) return value.map(answerString).join(", "); - return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value); -} - -function toAskUserResult( - questions: AskUserRequestParams["questions"], - answers: ProviderUserInputAnswers, -): AskUserResult { - return { - answers: questions.map((question, index) => ({ - index: question.index, - question: question.question, - answer: answerString( - answers[`question-${question.index ?? index}`] ?? answers[question.question], - ), - })), - }; -} - -function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome { - switch (decision) { - case "accept": - return ToolConfirmationOutcome.ProceedOnce; - case "acceptForSession": - return ToolConfirmationOutcome.ProceedAlways; - case "decline": - case "cancel": - return ToolConfirmationOutcome.Cancel; - } -} - -function toTokenUsageSnapshot(usage: TokenUsageUpdate) { - const inputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; - const outputTokens = usage.outputTokens + usage.thinkingTokens; - return { - usedTokens: inputTokens + outputTokens, - inputTokens, - cachedInputTokens: usage.cacheReadTokens, - outputTokens, - reasoningOutputTokens: usage.thinkingTokens, - lastInputTokens: inputTokens, - lastCachedInputTokens: usage.cacheReadTokens, - lastOutputTokens: outputTokens, - lastReasoningOutputTokens: usage.thinkingTokens, - }; -} +import { resolveDroidImages } from "../droid/DroidAttachmentResolver.ts"; +import { + DROID_PROVIDER, + type DroidAdapterOptions, + type DroidAdapterShape, + type DroidContext, +} from "../droid/DroidAdapterTypes.ts"; +import { + handleDroidMessage, + makeDroidEventBase, + nowIso, + updateDroidContextSession, +} from "../droid/DroidRuntimeEvents.ts"; +import { + DroidInteractionMode, + normalizeAskUserQuestions, + permissionDetail, + toAskUserResult, + toAutonomyLevel, + toModelId, + toOutcome, + toReasoningEffort, + toRequestType, +} from "../droid/DroidSdkMappings.ts"; + +export type { DroidAdapterOptions } from "../droid/DroidAdapterTypes.ts"; export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapterOptions) { return Effect.gen(function* () { @@ -312,85 +92,18 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); const emitNow = (event: ProviderRuntimeEvent) => runPromise(emit(event)); - const eventBase = ( - context: DroidContext, - input?: { - turnId?: TurnId; - itemId?: string; - requestId?: string; - raw?: unknown; - }, - ) => ({ - eventId: eventId(), - provider: PROVIDER, - providerInstanceId: instanceId, - threadId: context.session.threadId, - createdAt: nowIso(), - ...(input?.turnId ? { turnId: input.turnId } : {}), - ...(input?.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), - ...(input?.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), - ...(input?.raw !== undefined - ? { raw: { source: "droid.sdk.message" as const, payload: input.raw } } - : {}), - }); + const eventBase = makeDroidEventBase(instanceId); const requireSession = Effect.fn("requireDroidSession")(function* (threadId: ThreadId) { const context = sessions.get(threadId); if (!context) { return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + provider: DROID_PROVIDER, threadId, }); } return context; }); - type DroidAdapterShape = ProviderAdapterShape; - const resolveImages = Effect.fn("resolveDroidImages")(function* ( - input: NonNullable[0]["attachments"]>, - ) { - return yield* Effect.forEach( - input, - (attachment) => - Effect.gen(function* () { - if (!isSupportedDroidImageMimeType(attachment.mimeType)) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/start", - detail: `Unsupported Droid image attachment type '${attachment.mimeType}'.`, - }); - } - 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: "base64", - data: Buffer.from(bytes).toString("base64"), - mediaType: attachment.mimeType, - } satisfies Base64ImageSource; - }), - { concurrency: 1 }, - ); - }); - const startSession: DroidAdapterShape["startSession"] = Effect.fn("startDroidSession")( function* (input) { let contextRef: DroidContext | undefined; @@ -461,14 +174,14 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }), catch: (cause) => new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: DROID_PROVIDER, method: "createSession", detail: cause instanceof Error ? cause.message : "Failed to start Droid session.", cause, }), }); const session: ProviderSession = { - provider: PROVIDER, + provider: DROID_PROVIDER, providerInstanceId: instanceId, status: "ready", runtimeMode: input.runtimeMode, @@ -507,200 +220,23 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }, ); - const handleMessage = async (context: DroidContext, turnId: TurnId, message: DroidMessage) => { - const base = (itemId?: string) => - eventBase(context, { turnId, raw: message, ...(itemId ? { itemId } : {}) }); - switch (message.type) { - case DroidMessageType.AssistantTextDelta: - case DroidMessageType.ThinkingTextDelta: { - const itemId = `${message.messageId}-${message.blockIndex}`; - const streamKind: RuntimeContentStreamKind = - message.type === DroidMessageType.AssistantTextDelta - ? "assistant_text" - : "reasoning_text"; - if (streamKind === "assistant_text") { - context.activeAssistantItems.set( - itemId, - `${context.activeAssistantItems.get(itemId) ?? ""}${message.text}`, - ); - } - return emitNow({ - ...base(itemId), - type: "content.delta", - payload: { streamKind, delta: message.text }, - }); - } - case DroidMessageType.CreateMessage: { - if (message.role !== "assistant") { - return; - } - for (const [index, block] of message.content.entries()) { - const text = contentBlockText(block); - if (text.length === 0) { - continue; - } - const itemId = block.id ?? `${message.messageId}-${index}`; - if (block.type === "text") { - const previousText = context.activeAssistantItems.get(itemId) ?? ""; - const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; - if (delta.length > 0) { - await emitNow({ - ...base(itemId), - type: "content.delta", - payload: { streamKind: "assistant_text", delta }, - }); - } - context.activeAssistantItems.set(itemId, text); - continue; - } - if (block.type === "thinking") { - await emitNow({ - ...base(itemId), - type: "content.delta", - payload: { streamKind: "reasoning_text", delta: text }, - }); - } - } - - const firstTextIndex = message.content.findIndex((block) => block.type === "text"); - const firstTextBlock = message.content[firstTextIndex]; - const completedItemId = - firstTextBlock?.id ?? - (firstTextIndex >= 0 ? `${message.messageId}-${firstTextIndex}` : message.messageId); - if (!context.activeCompletedAssistantItems.has(completedItemId)) { - context.activeCompletedAssistantItems.add(completedItemId); - return emitNow({ - ...base(completedItemId), - type: "item.completed", - payload: { - itemType: "assistant_message", - status: "completed", - ...(firstTextBlock ? { detail: contentBlockText(firstTextBlock) } : {}), - }, - }); - } - return; - } - case DroidMessageType.ToolUse: - return emitNow({ - ...base(message.toolUseId), - type: "item.started", - payload: { - itemType: toToolItemType(message.toolName), - status: "inProgress", - title: message.toolName, - data: message.toolInput, - }, - }); - case DroidMessageType.ToolProgress: - return emitNow({ - ...base(message.toolUseId), - type: "item.updated", - payload: { - itemType: toToolItemType(message.toolName), - status: "inProgress", - title: message.toolName, - detail: message.content, - data: message.update, - }, - }); - case DroidMessageType.ToolResult: - return emitNow({ - ...base(message.toolUseId), - type: "item.completed", - payload: { - itemType: toToolItemType(message.toolName), - status: message.isError ? "failed" : "completed", - title: message.toolName, - detail: - typeof message.content === "string" - ? message.content - : JSON.stringify(message.content), - }, - }); - case DroidMessageType.WorkingStateChanged: - return emitNow({ - ...base(), - type: "session.state.changed", - payload: { - state: - message.state === "idle" - ? "ready" - : message.state.includes("waiting") - ? "waiting" - : "running", - detail: message, - }, - }); - case DroidMessageType.TokenUsageUpdate: - context.activeTokenUsage = message; - return emitNow({ - ...base(), - type: "thread.token-usage.updated", - payload: { usage: toTokenUsageSnapshot(message) }, - }); - case DroidMessageType.SessionTitleUpdated: - return emitNow({ - ...base(), - type: "thread.metadata.updated", - payload: { name: message.title }, - }); - case DroidMessageType.SettingsUpdated: - return emitNow({ - ...base(), - type: "session.configured", - payload: { config: message.settings }, - }); - case DroidMessageType.McpStatusChanged: - return emitNow({ - ...base(), - type: "mcp.status.updated", - payload: { status: message }, - }); - case DroidMessageType.McpAuthRequired: - return emitNow({ - ...base(), - type: "auth.status", - payload: { isAuthenticating: true, output: [message.message] }, - }); - case DroidMessageType.McpAuthCompleted: - return emitNow({ - ...base(), - type: "mcp.oauth.completed", - payload: { - success: message.outcome === "success", - name: message.serverName, - ...(message.outcome === "success" ? {} : { error: message.message }), - }, - }); - case DroidMessageType.Error: - return emitNow({ - ...base(), - type: "runtime.error", - payload: { message: message.message, class: "provider_error" }, - }); - case DroidMessageType.TurnComplete: - if (message.tokenUsage) context.activeTokenUsage = message.tokenUsage; - return Promise.resolve(); - default: - return Promise.resolve(); - } - }; - const sendTurn: DroidAdapterShape["sendTurn"] = Effect.fn("sendDroidTurn")(function* (input) { const context = sessions.get(input.threadId); if (!context) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: DROID_PROVIDER, operation: "sendTurn", issue: `Unknown Droid thread: ${input.threadId}`, }); } const text = input.input?.trim(); - const images = yield* resolveImages(input.attachments ?? []); + const images = yield* resolveDroidImages(input.attachments ?? [], { + attachmentsDir: serverConfig.attachmentsDir, + fileSystem, + }); if (!text && images.length === 0) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: DROID_PROVIDER, operation: "sendTurn", issue: "Droid turns require text input or at least one attachment.", }); @@ -713,7 +249,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter context.activeCompletedAssistantItems = new Set(); context.activeTokenUsage = undefined; context.turns.push({ id: turnId, items: [] }); - updateContextSession(context, { + updateDroidContextSession(context, { status: "running", activeTurnId: turnId, model: input.modelSelection?.model ?? context.session.model, @@ -755,7 +291,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter text || "Please respond to the attached image.", messageOptions, )) { - await handleMessage(context, turnId, message); + await handleDroidMessage({ context, turnId, message, eventBase, emitNow }); } for (const [itemId, detail] of context.activeAssistantItems) { if (context.activeCompletedAssistantItems.has(itemId)) { @@ -767,7 +303,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter payload: { itemType: "assistant_message", status: "completed", detail }, }); } - updateContextSession(context, { status: "ready", activeTurnId: undefined }); + updateDroidContextSession(context, { status: "ready", activeTurnId: undefined }); await emitNow({ ...eventBase(context, { turnId }), type: "turn.completed", @@ -778,7 +314,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); } catch (cause) { const message = cause instanceof Error ? cause.message : "Droid turn failed."; - updateContextSession(context, { + updateDroidContextSession(context, { status: "error", activeTurnId: undefined, lastError: message, @@ -814,7 +350,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); return { - provider: PROVIDER, + provider: DROID_PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, @@ -830,7 +366,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const pending = context?.pendingPermissions.get(requestId); if (!context || !pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: DROID_PROVIDER, method: "respondToRequest", detail: `Unknown pending Droid permission request: ${requestId}`, }); @@ -849,7 +385,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const pending = context?.pendingUserInputs.get(requestId); if (!context || !pending) { return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, + provider: DROID_PROVIDER, method: "respondToUserInput", detail: `Unknown pending Droid user-input request: ${requestId}`, }); @@ -875,7 +411,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const context = yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider: DROID_PROVIDER, operation: "rollbackThread", issue: "numTurns must be an integer >= 1.", }); diff --git a/apps/server/src/provider/droid/DroidAdapterTypes.ts b/apps/server/src/provider/droid/DroidAdapterTypes.ts new file mode 100644 index 00000000000..92e63966c07 --- /dev/null +++ b/apps/server/src/provider/droid/DroidAdapterTypes.ts @@ -0,0 +1,60 @@ +import type { + AskUserRequestParams, + AskUserResult, + CreateSessionOptions, + DroidSession, + ResumeSessionOptions, + ToolConfirmationOutcome, + TokenUsageUpdate, +} from "@factory/droid-sdk"; +import { + ApprovalRequestId, + ProviderDriverKind, + type CanonicalRequestType, + type ProviderInstanceId, + type ProviderSession, + type TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +export const DROID_PROVIDER = ProviderDriverKind.make("droid"); + +export interface PendingDroidPermission { + readonly requestType: CanonicalRequestType; + readonly resolve: (decision: ToolConfirmationOutcome) => void; +} + +export interface PendingDroidUserInput { + readonly questions: ReadonlyArray; + readonly droidQuestions: AskUserRequestParams["questions"]; + readonly resolve: (result: AskUserResult) => void; +} + +export interface DroidContext { + session: ProviderSession; + readonly droid: DroidSession; + readonly pendingPermissions: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeAbort: AbortController | undefined; + activeAssistantItems: Map; + activeCompletedAssistantItems: Set; + activeTokenUsage: TokenUsageUpdate | undefined; +} + +export interface DroidAdapterOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly sdk?: { + readonly createSession: (options?: CreateSessionOptions) => Promise; + readonly resumeSession: ( + sessionId: string, + options?: ResumeSessionOptions, + ) => Promise; + }; +} + +export type DroidAdapterShape = ProviderAdapterShape; diff --git a/apps/server/src/provider/droid/DroidAttachmentResolver.ts b/apps/server/src/provider/droid/DroidAttachmentResolver.ts new file mode 100644 index 00000000000..29bcf83f6b6 --- /dev/null +++ b/apps/server/src/provider/droid/DroidAttachmentResolver.ts @@ -0,0 +1,72 @@ +import type { Base64ImageSource } from "@factory/droid-sdk"; +import type * as FileSystem from "effect/FileSystem"; +import * as Effect from "effect/Effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { DROID_PROVIDER, type DroidAdapterShape } from "./DroidAdapterTypes.ts"; + +const SUPPORTED_DROID_IMAGE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; + +type SupportedDroidImageMimeType = (typeof SUPPORTED_DROID_IMAGE_MIME_TYPES)[number]; + +const isSupportedDroidImageMimeType = (value: string): value is SupportedDroidImageMimeType => + (SUPPORTED_DROID_IMAGE_MIME_TYPES as ReadonlyArray).includes(value); + +type DroidAttachments = NonNullable[0]["attachments"]>; + +export function resolveDroidImages( + attachments: DroidAttachments, + dependencies: { + readonly attachmentsDir: string; + readonly fileSystem: FileSystem.FileSystem; + }, +) { + const { attachmentsDir, fileSystem } = dependencies; + return Effect.forEach( + attachments, + (attachment) => + Effect.gen(function* () { + if (!isSupportedDroidImageMimeType(attachment.mimeType)) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Unsupported Droid image attachment type '${attachment.mimeType}'.`, + }); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Failed to read attachment file: ${cause.message}.`, + cause, + }), + ), + ); + return { + type: "base64", + data: Buffer.from(bytes).toString("base64"), + mediaType: attachment.mimeType, + } satisfies Base64ImageSource; + }), + { concurrency: 1 }, + ); +} diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts new file mode 100644 index 00000000000..1248be6cb64 --- /dev/null +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -0,0 +1,238 @@ +import { randomUUID } from "node:crypto"; +import { DroidMessageType, type DroidMessage, type TokenUsageUpdate } from "@factory/droid-sdk"; +import { + EventId, + RuntimeItemId, + RuntimeRequestId, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type RuntimeContentStreamKind, + type TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { DROID_PROVIDER, type DroidContext } from "./DroidAdapterTypes.ts"; +import { contentBlockText, toTokenUsageSnapshot, toToolItemType } from "./DroidSdkMappings.ts"; + +export const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); + +export function updateDroidContextSession( + context: DroidContext, + patch: Partial, +) { + context.session = { + ...context.session, + ...patch, + updatedAt: nowIso(), + }; +} + +export function makeDroidEventBase(instanceId: ProviderInstanceId) { + return ( + context: DroidContext, + input?: { + turnId?: TurnId; + itemId?: string; + requestId?: string; + raw?: unknown; + }, + ) => ({ + eventId: EventId.make(randomUUID()), + provider: DROID_PROVIDER, + providerInstanceId: instanceId, + threadId: context.session.threadId, + createdAt: nowIso(), + ...(input?.turnId ? { turnId: input.turnId } : {}), + ...(input?.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input?.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input?.raw !== undefined + ? { raw: { source: "droid.sdk.message" as const, payload: input.raw } } + : {}), + }); +} + +type DroidEventBase = ReturnType; + +export async function handleDroidMessage(input: { + readonly context: DroidContext; + readonly turnId: TurnId; + readonly message: DroidMessage; + readonly eventBase: DroidEventBase; + readonly emitNow: (event: ProviderRuntimeEvent) => Promise; +}) { + const { context, turnId, message, eventBase, emitNow } = input; + const base = (itemId?: string) => + eventBase(context, { turnId, raw: message, ...(itemId ? { itemId } : {}) }); + + switch (message.type) { + case DroidMessageType.AssistantTextDelta: + case DroidMessageType.ThinkingTextDelta: { + const itemId = `${message.messageId}-${message.blockIndex}`; + const streamKind: RuntimeContentStreamKind = + message.type === DroidMessageType.AssistantTextDelta ? "assistant_text" : "reasoning_text"; + if (streamKind === "assistant_text") { + context.activeAssistantItems.set( + itemId, + `${context.activeAssistantItems.get(itemId) ?? ""}${message.text}`, + ); + } + return emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind, delta: message.text }, + }); + } + case DroidMessageType.CreateMessage: { + if (message.role !== "assistant") { + return; + } + for (const [index, block] of message.content.entries()) { + const text = contentBlockText(block); + if (text.length === 0) { + continue; + } + const itemId = block.id ?? `${message.messageId}-${index}`; + if (block.type === "text") { + const previousText = context.activeAssistantItems.get(itemId) ?? ""; + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta.length > 0) { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "assistant_text", delta }, + }); + } + context.activeAssistantItems.set(itemId, text); + continue; + } + if (block.type === "thinking") { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: text }, + }); + } + } + + const firstTextIndex = message.content.findIndex((block) => block.type === "text"); + const firstTextBlock = message.content[firstTextIndex]; + const completedItemId = + firstTextBlock?.id ?? + (firstTextIndex >= 0 ? `${message.messageId}-${firstTextIndex}` : message.messageId); + if (!context.activeCompletedAssistantItems.has(completedItemId)) { + context.activeCompletedAssistantItems.add(completedItemId); + return emitNow({ + ...base(completedItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + ...(firstTextBlock ? { detail: contentBlockText(firstTextBlock) } : {}), + }, + }); + } + return; + } + case DroidMessageType.ToolUse: + return emitNow({ + ...base(message.toolUseId), + type: "item.started", + payload: { + itemType: toToolItemType(message.toolName), + status: "inProgress", + title: message.toolName, + data: message.toolInput, + }, + }); + case DroidMessageType.ToolProgress: + return emitNow({ + ...base(message.toolUseId), + type: "item.updated", + payload: { + itemType: toToolItemType(message.toolName), + status: "inProgress", + title: message.toolName, + detail: message.content, + data: message.update, + }, + }); + case DroidMessageType.ToolResult: + return emitNow({ + ...base(message.toolUseId), + type: "item.completed", + payload: { + itemType: toToolItemType(message.toolName), + status: message.isError ? "failed" : "completed", + title: message.toolName, + detail: + typeof message.content === "string" ? message.content : JSON.stringify(message.content), + }, + }); + case DroidMessageType.WorkingStateChanged: + return emitNow({ + ...base(), + type: "session.state.changed", + payload: { + state: + message.state === "idle" + ? "ready" + : message.state.includes("waiting") + ? "waiting" + : "running", + detail: message, + }, + }); + case DroidMessageType.TokenUsageUpdate: + context.activeTokenUsage = message as TokenUsageUpdate; + return emitNow({ + ...base(), + type: "thread.token-usage.updated", + payload: { usage: toTokenUsageSnapshot(message) }, + }); + case DroidMessageType.SessionTitleUpdated: + return emitNow({ + ...base(), + type: "thread.metadata.updated", + payload: { name: message.title }, + }); + case DroidMessageType.SettingsUpdated: + return emitNow({ + ...base(), + type: "session.configured", + payload: { config: message.settings }, + }); + case DroidMessageType.McpStatusChanged: + return emitNow({ + ...base(), + type: "mcp.status.updated", + payload: { status: message }, + }); + case DroidMessageType.McpAuthRequired: + return emitNow({ + ...base(), + type: "auth.status", + payload: { isAuthenticating: true, output: [message.message] }, + }); + case DroidMessageType.McpAuthCompleted: + return emitNow({ + ...base(), + type: "mcp.oauth.completed", + payload: { + success: message.outcome === "success", + name: message.serverName, + ...(message.outcome === "success" ? {} : { error: message.message }), + }, + }); + case DroidMessageType.Error: + return emitNow({ + ...base(), + type: "runtime.error", + payload: { message: message.message, class: "provider_error" }, + }); + case DroidMessageType.TurnComplete: + if (message.tokenUsage) context.activeTokenUsage = message.tokenUsage; + return; + default: + return; + } +} diff --git a/apps/server/src/provider/droid/DroidSdkMappings.ts b/apps/server/src/provider/droid/DroidSdkMappings.ts new file mode 100644 index 00000000000..5cd9cd20c33 --- /dev/null +++ b/apps/server/src/provider/droid/DroidSdkMappings.ts @@ -0,0 +1,186 @@ +import { + AutonomyLevel, + type AskUserRequestParams, + type AskUserResult, + type ContentBlock, + DroidInteractionMode, + ReasoningEffort, + ToolConfirmationOutcome, + ToolConfirmationType, + type RequestPermissionRequestParams, + type TokenUsageUpdate, +} from "@factory/droid-sdk"; +import type { + CanonicalRequestType, + ProviderApprovalDecision, + ProviderSessionStartInput, + ProviderUserInputAnswers, + ToolLifecycleItemType, + UserInputQuestion, +} from "@t3tools/contracts"; + +export { DroidInteractionMode }; + +export function toModelId(model: string | undefined): string | undefined { + return !model || model === "default" ? undefined : model; +} + +export function toReasoningEffort(value: string | undefined): ReasoningEffort | undefined { + switch (value) { + case "none": + return ReasoningEffort.None; + case "dynamic": + return ReasoningEffort.Dynamic; + case "off": + return ReasoningEffort.Off; + case "minimal": + return ReasoningEffort.Minimal; + case "low": + return ReasoningEffort.Low; + case "medium": + return ReasoningEffort.Medium; + case "high": + return ReasoningEffort.High; + case "xhigh": + return ReasoningEffort.ExtraHigh; + case "max": + return ReasoningEffort.Max; + default: + return undefined; + } +} + +export function toAutonomyLevel(input: ProviderSessionStartInput): AutonomyLevel { + switch (input.runtimeMode) { + case "approval-required": + return AutonomyLevel.Off; + case "auto-accept-edits": + return AutonomyLevel.Low; + case "medium-access": + return AutonomyLevel.Medium; + case "full-access": + return AutonomyLevel.High; + } +} + +export function contentBlockText(block: ContentBlock): string { + if (block.type === "text") return block.text; + if (block.type === "thinking") return block.thinking; + return ""; +} + +export function toRequestType(params: RequestPermissionRequestParams): CanonicalRequestType { + const type = params.toolUses[0]?.confirmationType; + switch (type) { + case ToolConfirmationType.Execute: + return "command_execution_approval"; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "file_change_approval"; + case ToolConfirmationType.McpTool: + return "dynamic_tool_call"; + case ToolConfirmationType.AskUser: + return "tool_user_input"; + default: + return "unknown"; + } +} + +export function toToolItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("exec") || + normalized.includes("bash") || + normalized.includes("command") + ) { + return "command_execution"; + } + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("web")) return "web_search"; + if (normalized.includes("image")) return "image_view"; + return "dynamic_tool_call"; +} + +export function permissionDetail(params: RequestPermissionRequestParams): string { + const first = params.toolUses[0]; + if (!first) return "Droid requested permission."; + const details = first.details; + switch (details.type) { + case ToolConfirmationType.Execute: + return details.fullCommand; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "filePath" in details ? details.filePath : "Droid requested a file change."; + case ToolConfirmationType.McpTool: + return details.toolName; + default: + return first.toolUse.name; + } +} + +export function normalizeAskUserQuestions( + params: AskUserRequestParams, +): ReadonlyArray { + return params.questions.map((question, index) => ({ + id: `question-${question.index ?? index}`, + header: question.topic || `Question ${index + 1}`, + question: question.question, + options: question.options.map((option) => ({ + label: option, + description: option, + })), + })); +} + +function answerString(value: unknown): string { + if (Array.isArray(value)) return value.map(answerString).join(", "); + return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value); +} + +export function toAskUserResult( + questions: AskUserRequestParams["questions"], + answers: ProviderUserInputAnswers, +): AskUserResult { + return { + answers: questions.map((question, index) => ({ + index: question.index, + question: question.question, + answer: answerString( + answers[`question-${question.index ?? index}`] ?? answers[question.question], + ), + })), + }; +} + +export function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome { + switch (decision) { + case "accept": + return ToolConfirmationOutcome.ProceedOnce; + case "acceptForSession": + return ToolConfirmationOutcome.ProceedAlways; + case "decline": + case "cancel": + return ToolConfirmationOutcome.Cancel; + } +} + +export function toTokenUsageSnapshot(usage: TokenUsageUpdate) { + const inputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; + const outputTokens = usage.outputTokens + usage.thinkingTokens; + return { + usedTokens: inputTokens + outputTokens, + inputTokens, + cachedInputTokens: usage.cacheReadTokens, + outputTokens, + reasoningOutputTokens: usage.thinkingTokens, + lastInputTokens: inputTokens, + lastCachedInputTokens: usage.cacheReadTokens, + lastOutputTokens: outputTokens, + lastReasoningOutputTokens: usage.thinkingTokens, + }; +} From 8939e5704ac88cea754ffb7aba33c0fc504a0037 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Fri, 15 May 2026 23:42:51 -0400 Subject: [PATCH 07/11] Address Droid PR review comments --- .../src/provider/Layers/DroidProvider.test.ts | 19 ++++++++++++++- .../src/provider/Layers/DroidProvider.ts | 2 +- apps/web/src/components/ChatView.tsx | 18 ++++++++++++++- .../chat/runtimeModePresentation.test.ts | 23 +++++++++++++++++++ .../chat/runtimeModePresentation.ts | 7 ++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/chat/runtimeModePresentation.test.ts diff --git a/apps/server/src/provider/Layers/DroidProvider.test.ts b/apps/server/src/provider/Layers/DroidProvider.test.ts index edc72a8a1df..6486d7ba0b5 100644 --- a/apps/server/src/provider/Layers/DroidProvider.test.ts +++ b/apps/server/src/provider/Layers/DroidProvider.test.ts @@ -1,11 +1,28 @@ import { ModelProvider, ReasoningEffort, type AvailableModelConfig } from "@factory/droid-sdk"; +import { DroidSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; -import { buildDroidModelsFromSdkModels } from "./DroidProvider.ts"; +import { buildDroidModelsFromSdkModels, makePendingDroidProvider } from "./DroidProvider.ts"; const sdkModel = (model: AvailableModelConfig): AvailableModelConfig => model; +const decodeDroidSettings = Schema.decodeSync(DroidSettings); describe("DroidProvider", () => { + it("reports disabled pending provider status when Droid is disabled", async () => { + const settings = decodeDroidSettings({ + enabled: false, + binaryPath: "fake-droid", + }); + const provider = await Effect.runPromise(makePendingDroidProvider(settings)); + + expect(provider.enabled).toBe(false); + expect(provider.status).toBe("disabled"); + expect(provider.installed).toBe(false); + expect(provider.message).toBe("Droid is disabled in T3 Code settings."); + }); + it("maps Droid SDK built-in and custom models into provider models", () => { const models = buildDroidModelsFromSdkModels([ sdkModel({ diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts index cfe3aad8875..cc07ddb9c97 100644 --- a/apps/server/src/provider/Layers/DroidProvider.ts +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -231,7 +231,7 @@ export function makePendingDroidProvider( probe: { installed: settings.enabled, version: null, - status: settings.enabled ? "warning" : "warning", + status: "warning", auth: { status: "unknown" }, message: settings.enabled ? "Checking Droid availability..." diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..874c674ad97 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -147,6 +147,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { normalizeRuntimeModeForProvider } from "./chat/runtimeModePresentation"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -807,7 +808,7 @@ export default function ChatView(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const rawRuntimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; @@ -1262,6 +1263,21 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const runtimeMode = normalizeRuntimeModeForProvider(selectedProvider, rawRuntimeMode); + useEffect(() => { + if (runtimeMode === rawRuntimeMode) return; + setComposerDraftRuntimeMode(composerDraftTarget, runtimeMode); + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { runtimeMode }); + } + }, [ + composerDraftTarget, + isLocalDraftThread, + rawRuntimeMode, + runtimeMode, + setComposerDraftRuntimeMode, + setDraftThreadContext, + ]); const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( diff --git a/apps/web/src/components/chat/runtimeModePresentation.test.ts b/apps/web/src/components/chat/runtimeModePresentation.test.ts new file mode 100644 index 00000000000..e3a613f6c54 --- /dev/null +++ b/apps/web/src/components/chat/runtimeModePresentation.test.ts @@ -0,0 +1,23 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { getRuntimeModeOptions, normalizeRuntimeModeForProvider } from "./runtimeModePresentation"; + +describe("runtimeModePresentation", () => { + it("keeps medium access available only for Droid", () => { + expect(getRuntimeModeOptions(ProviderDriverKind.make("droid"))).toContain("medium-access"); + expect(getRuntimeModeOptions(ProviderDriverKind.make("codex"))).not.toContain("medium-access"); + }); + + it("normalizes Droid-only medium access when switching to another provider", () => { + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("codex"), "medium-access")).toBe( + "auto-accept-edits", + ); + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("droid"), "medium-access")).toBe( + "medium-access", + ); + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("codex"), "full-access")).toBe( + "full-access", + ); + }); +}); diff --git a/apps/web/src/components/chat/runtimeModePresentation.ts b/apps/web/src/components/chat/runtimeModePresentation.ts index 2b1f4926ea5..283e1d08df8 100644 --- a/apps/web/src/components/chat/runtimeModePresentation.ts +++ b/apps/web/src/components/chat/runtimeModePresentation.ts @@ -78,3 +78,10 @@ export function getRuntimeModeOptions(provider: ProviderDriverKind): ReadonlyArr ? DROID_RUNTIME_MODE_OPTIONS : BASE_RUNTIME_MODE_OPTIONS; } + +export function normalizeRuntimeModeForProvider( + provider: ProviderDriverKind, + runtimeMode: RuntimeMode, +): RuntimeMode { + return getRuntimeModeOptions(provider).includes(runtimeMode) ? runtimeMode : "auto-accept-edits"; +} From 742ab641d52540cf43b4868c66cc3160dc94bf0e Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Fri, 15 May 2026 23:53:41 -0400 Subject: [PATCH 08/11] Normalize Droid turn usage payload --- .../src/provider/Layers/DroidAdapter.test.ts | 19 +++++++++++++++++++ .../src/provider/droid/DroidAdapterTypes.ts | 4 ++-- .../src/provider/droid/DroidRuntimeEvents.ts | 8 ++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 8a1b40b3f2a..49b6df4ae2e 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -174,6 +174,25 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => "turn.completed", ], ); + const expectedUsage = { + usedTokens: 20, + inputTokens: 15, + cachedInputTokens: 3, + outputTokens: 5, + reasoningOutputTokens: 1, + lastInputTokens: 15, + lastCachedInputTokens: 3, + lastOutputTokens: 5, + lastReasoningOutputTokens: 1, + }; + assert.deepEqual( + events.find((event) => event.type === "thread.token-usage.updated")?.payload, + { usage: expectedUsage }, + ); + assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + state: "completed", + usage: expectedUsage, + }); }), ).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/provider/droid/DroidAdapterTypes.ts b/apps/server/src/provider/droid/DroidAdapterTypes.ts index 92e63966c07..f4ed4d202a0 100644 --- a/apps/server/src/provider/droid/DroidAdapterTypes.ts +++ b/apps/server/src/provider/droid/DroidAdapterTypes.ts @@ -5,7 +5,6 @@ import type { DroidSession, ResumeSessionOptions, ToolConfirmationOutcome, - TokenUsageUpdate, } from "@factory/droid-sdk"; import { ApprovalRequestId, @@ -13,6 +12,7 @@ import { type CanonicalRequestType, type ProviderInstanceId, type ProviderSession, + type ThreadTokenUsageSnapshot, type TurnId, type UserInputQuestion, } from "@t3tools/contracts"; @@ -42,7 +42,7 @@ export interface DroidContext { activeAbort: AbortController | undefined; activeAssistantItems: Map; activeCompletedAssistantItems: Set; - activeTokenUsage: TokenUsageUpdate | undefined; + activeTokenUsage: ThreadTokenUsageSnapshot | undefined; } export interface DroidAdapterOptions { diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index 1248be6cb64..f374562b4ae 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { DroidMessageType, type DroidMessage, type TokenUsageUpdate } from "@factory/droid-sdk"; +import { DroidMessageType, type DroidMessage } from "@factory/droid-sdk"; import { EventId, RuntimeItemId, @@ -183,11 +183,11 @@ export async function handleDroidMessage(input: { }, }); case DroidMessageType.TokenUsageUpdate: - context.activeTokenUsage = message as TokenUsageUpdate; + context.activeTokenUsage = toTokenUsageSnapshot(message); return emitNow({ ...base(), type: "thread.token-usage.updated", - payload: { usage: toTokenUsageSnapshot(message) }, + payload: { usage: context.activeTokenUsage }, }); case DroidMessageType.SessionTitleUpdated: return emitNow({ @@ -230,7 +230,7 @@ export async function handleDroidMessage(input: { payload: { message: message.message, class: "provider_error" }, }); case DroidMessageType.TurnComplete: - if (message.tokenUsage) context.activeTokenUsage = message.tokenUsage; + if (message.tokenUsage) context.activeTokenUsage = toTokenUsageSnapshot(message.tokenUsage); return; default: return; From 0944b58116849ae8963a67b4c370bfaffd435cbc Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sat, 16 May 2026 00:09:20 -0400 Subject: [PATCH 09/11] Harden Droid thinking and interrupt handling --- .../src/provider/Layers/DroidAdapter.test.ts | 97 ++++++++++++++++++- .../src/provider/Layers/DroidAdapter.ts | 9 +- .../src/provider/droid/DroidAdapterTypes.ts | 1 + .../src/provider/droid/DroidRuntimeEvents.ts | 26 ++--- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 49b6df4ae2e..ea24ee5af96 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -41,6 +41,7 @@ function fakeSession(input: { readonly messages?: ReadonlyArray; readonly onStream?: () => AsyncGenerator; readonly onClose?: () => Promise; + readonly onInterrupt?: () => Promise; readonly onEnterSpecMode?: (params: unknown) => void; readonly onUpdateSettings?: (params: unknown) => void; }): DroidSession { @@ -65,7 +66,7 @@ function fakeSession(input: { structuredOutput: null, success: true, }), - interrupt: async () => undefined, + interrupt: input.onInterrupt ?? (async () => undefined), close: input.onClose ?? (async () => undefined), updateSettings: async (params: unknown) => { input.onUpdateSettings?.(params); @@ -340,6 +341,100 @@ it.effect("does not duplicate Droid final create_message text after streaming de ).pipe(Effect.provide(testLayer)), ); +it.effect("does not duplicate Droid final thinking content after streaming deltas", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.ThinkingTextDelta, + messageId: "assistant-thinking", + blockIndex: 0, + text: "thi", + }, + { + type: DroidMessageType.ThinkingTextDelta, + messageId: "assistant-thinking", + blockIndex: 0, + text: "nk", + }, + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-thinking", + role: "assistant", + content: [ + { type: "thinking", signature: "test-signature", thinking: "think" }, + { type: "text", text: "answer" }, + ], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(8), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), + [ + { streamKind: "reasoning_text", delta: "thi" }, + { streamKind: "reasoning_text", delta: "nk" }, + { streamKind: "assistant_text", delta: "answer" }, + ], + ); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("ignores Droid interrupt failures after aborting the active turn", () => + Effect.scoped( + Effect.gen(function* () { + let interruptAttempts = 0; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + onInterrupt: async () => { + interruptAttempts += 1; + throw new Error("interrupt failed"); + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + + const exit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); + assert.equal(exit._tag, "Success"); + assert.equal(interruptAttempts, 1); + }), + ).pipe(Effect.provide(testLayer)), +); + it.effect("passes custom model reasoning into Droid spec mode", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index 879e25fa8c5..6353474c2c4 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -200,6 +200,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter turns: [], activeAbort: undefined, activeAssistantItems: new Map(), + activeThinkingItems: new Map(), activeCompletedAssistantItems: new Set(), activeTokenUsage: undefined, }; @@ -246,6 +247,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter const abort = new AbortController(); context.activeAbort = abort; context.activeAssistantItems = new Map(); + context.activeThinkingItems = new Map(); context.activeCompletedAssistantItems = new Set(); context.activeTokenUsage = undefined; context.turns.push({ id: turnId, items: [] }); @@ -355,10 +357,11 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter startSession, sendTurn, interruptTurn: (threadId) => - Effect.promise(async () => { + Effect.gen(function* () { const context = sessions.get(threadId); - context?.activeAbort?.abort(); - await context?.droid.interrupt(); + if (!context) return; + context.activeAbort?.abort(); + yield* Effect.tryPromise(() => context.droid.interrupt()).pipe(Effect.ignore); }), respondToRequest: (threadId, requestId, decision) => Effect.gen(function* () { diff --git a/apps/server/src/provider/droid/DroidAdapterTypes.ts b/apps/server/src/provider/droid/DroidAdapterTypes.ts index f4ed4d202a0..e160c2d915f 100644 --- a/apps/server/src/provider/droid/DroidAdapterTypes.ts +++ b/apps/server/src/provider/droid/DroidAdapterTypes.ts @@ -41,6 +41,7 @@ export interface DroidContext { readonly turns: Array<{ id: TurnId; items: Array }>; activeAbort: AbortController | undefined; activeAssistantItems: Map; + activeThinkingItems: Map; activeCompletedAssistantItems: Set; activeTokenUsage: ThreadTokenUsageSnapshot | undefined; } diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index f374562b4ae..3576ee7ee82 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -70,12 +70,11 @@ export async function handleDroidMessage(input: { const itemId = `${message.messageId}-${message.blockIndex}`; const streamKind: RuntimeContentStreamKind = message.type === DroidMessageType.AssistantTextDelta ? "assistant_text" : "reasoning_text"; - if (streamKind === "assistant_text") { - context.activeAssistantItems.set( - itemId, - `${context.activeAssistantItems.get(itemId) ?? ""}${message.text}`, - ); - } + const activeItems = + streamKind === "assistant_text" + ? context.activeAssistantItems + : context.activeThinkingItems; + activeItems.set(itemId, `${activeItems.get(itemId) ?? ""}${message.text}`); return emitNow({ ...base(itemId), type: "content.delta", @@ -106,11 +105,16 @@ export async function handleDroidMessage(input: { continue; } if (block.type === "thinking") { - await emitNow({ - ...base(itemId), - type: "content.delta", - payload: { streamKind: "reasoning_text", delta: text }, - }); + const previousText = context.activeThinkingItems.get(itemId) ?? ""; + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta.length > 0) { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "reasoning_text", delta }, + }); + } + context.activeThinkingItems.set(itemId, text); } } From a39cdf19b207a0d54ecf17fc7650388cd0999592 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sat, 16 May 2026 00:22:52 -0400 Subject: [PATCH 10/11] Preserve Droid cumulative token usage --- .../src/provider/Layers/DroidAdapter.test.ts | 113 ++++++++++++++++++ .../src/provider/Layers/DroidAdapter.ts | 3 + .../src/provider/droid/DroidAdapterTypes.ts | 2 + .../src/provider/droid/DroidRuntimeEvents.ts | 11 +- .../src/provider/droid/DroidSdkMappings.ts | 29 +++-- 5 files changed, 147 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index ea24ee5af96..2b2964b0091 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -181,6 +181,7 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => cachedInputTokens: 3, outputTokens: 5, reasoningOutputTokens: 1, + lastUsedTokens: 20, lastInputTokens: 15, lastCachedInputTokens: 3, lastOutputTokens: 5, @@ -198,6 +199,118 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => ).pipe(Effect.provide(testLayer)), ); +it.effect("keeps Droid token usage cumulative across turns", () => + Effect.scoped( + Effect.gen(function* () { + const usageThreadId = ThreadId.make("thread-droid-token-usage"); + let streamCalls = 0; + const turnUsages: ReadonlyArray = [ + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 10, + outputTokens: 4, + cacheCreationTokens: 2, + cacheReadTokens: 3, + thinkingTokens: 1, + }, + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 5, + outputTokens: 7, + cacheCreationTokens: 0, + cacheReadTokens: 1, + thinkingTokens: 2, + }, + ]; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + onStream: async function* () { + const usage = turnUsages[streamCalls]; + streamCalls += 1; + if (!usage) throw new Error("Unexpected extra Droid turn stream."); + yield usage; + yield { type: DroidMessageType.TurnComplete, tokenUsage: null }; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const firstEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === usageThreadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: usageThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: usageThreadId, input: "first" }); + const firstEvents = Array.from( + yield* Fiber.join(firstEventsFiber).pipe(Effect.timeout("2 seconds")), + ); + + const secondEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === usageThreadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ threadId: usageThreadId, input: "second" }); + const secondEvents = Array.from( + yield* Fiber.join(secondEventsFiber).pipe(Effect.timeout("2 seconds")), + ); + const events = [...firstEvents, ...secondEvents]; + const usageEvents = events.filter((event) => event.type === "thread.token-usage.updated"); + const completedTurns = events.filter((event) => event.type === "turn.completed"); + + assert.deepEqual( + usageEvents.map((event) => + event.type === "thread.token-usage.updated" ? event.payload.usage : undefined, + ), + [ + { + usedTokens: 20, + inputTokens: 15, + cachedInputTokens: 3, + outputTokens: 5, + reasoningOutputTokens: 1, + lastUsedTokens: 20, + lastInputTokens: 15, + lastCachedInputTokens: 3, + lastOutputTokens: 5, + lastReasoningOutputTokens: 1, + }, + { + usedTokens: 35, + inputTokens: 21, + cachedInputTokens: 4, + outputTokens: 14, + reasoningOutputTokens: 3, + lastUsedTokens: 15, + lastInputTokens: 6, + lastCachedInputTokens: 1, + lastOutputTokens: 9, + lastReasoningOutputTokens: 2, + }, + ], + ); + assert.deepEqual( + completedTurns.map((event) => + event.type === "turn.completed" + ? (event.payload as { usage?: { usedTokens?: number } }).usage?.usedTokens + : undefined, + ), + [20, 35], + ); + }), + ).pipe(Effect.provide(testLayer)), +); + it.effect("maps Droid medium access to medium autonomy", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index 6353474c2c4..7737ce983b0 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -203,6 +203,8 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter activeThinkingItems: new Map(), activeCompletedAssistantItems: new Set(), activeTokenUsage: undefined, + activeTokenUsageBaseline: undefined, + cumulativeTokenUsage: undefined, }; contextRef = context; sessions.set(input.threadId, context); @@ -250,6 +252,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter context.activeThinkingItems = new Map(); context.activeCompletedAssistantItems = new Set(); context.activeTokenUsage = undefined; + context.activeTokenUsageBaseline = context.cumulativeTokenUsage; context.turns.push({ id: turnId, items: [] }); updateDroidContextSession(context, { status: "running", diff --git a/apps/server/src/provider/droid/DroidAdapterTypes.ts b/apps/server/src/provider/droid/DroidAdapterTypes.ts index e160c2d915f..fb323efa201 100644 --- a/apps/server/src/provider/droid/DroidAdapterTypes.ts +++ b/apps/server/src/provider/droid/DroidAdapterTypes.ts @@ -44,6 +44,8 @@ export interface DroidContext { activeThinkingItems: Map; activeCompletedAssistantItems: Set; activeTokenUsage: ThreadTokenUsageSnapshot | undefined; + activeTokenUsageBaseline: ThreadTokenUsageSnapshot | undefined; + cumulativeTokenUsage: ThreadTokenUsageSnapshot | undefined; } export interface DroidAdapterOptions { diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index 3576ee7ee82..2d76ead29ff 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -187,7 +187,8 @@ export async function handleDroidMessage(input: { }, }); case DroidMessageType.TokenUsageUpdate: - context.activeTokenUsage = toTokenUsageSnapshot(message); + context.activeTokenUsage = toTokenUsageSnapshot(message, context.activeTokenUsageBaseline); + context.cumulativeTokenUsage = context.activeTokenUsage; return emitNow({ ...base(), type: "thread.token-usage.updated", @@ -234,7 +235,13 @@ export async function handleDroidMessage(input: { payload: { message: message.message, class: "provider_error" }, }); case DroidMessageType.TurnComplete: - if (message.tokenUsage) context.activeTokenUsage = toTokenUsageSnapshot(message.tokenUsage); + if (message.tokenUsage) { + context.activeTokenUsage = toTokenUsageSnapshot( + message.tokenUsage, + context.activeTokenUsageBaseline, + ); + } + context.cumulativeTokenUsage = context.activeTokenUsage ?? context.cumulativeTokenUsage; return; default: return; diff --git a/apps/server/src/provider/droid/DroidSdkMappings.ts b/apps/server/src/provider/droid/DroidSdkMappings.ts index 5cd9cd20c33..54be9342985 100644 --- a/apps/server/src/provider/droid/DroidSdkMappings.ts +++ b/apps/server/src/provider/droid/DroidSdkMappings.ts @@ -15,6 +15,7 @@ import type { ProviderApprovalDecision, ProviderSessionStartInput, ProviderUserInputAnswers, + ThreadTokenUsageSnapshot, ToolLifecycleItemType, UserInputQuestion, } from "@t3tools/contracts"; @@ -169,18 +170,28 @@ export function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationO } } -export function toTokenUsageSnapshot(usage: TokenUsageUpdate) { - const inputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; - const outputTokens = usage.outputTokens + usage.thinkingTokens; +export function toTokenUsageSnapshot( + usage: TokenUsageUpdate, + previous?: ThreadTokenUsageSnapshot, +): ThreadTokenUsageSnapshot { + const lastInputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; + const lastOutputTokens = usage.outputTokens + usage.thinkingTokens; + const lastCachedInputTokens = usage.cacheReadTokens; + const lastReasoningOutputTokens = usage.thinkingTokens; + const inputTokens = (previous?.inputTokens ?? 0) + lastInputTokens; + const cachedInputTokens = (previous?.cachedInputTokens ?? 0) + lastCachedInputTokens; + const outputTokens = (previous?.outputTokens ?? 0) + lastOutputTokens; + const reasoningOutputTokens = (previous?.reasoningOutputTokens ?? 0) + lastReasoningOutputTokens; return { usedTokens: inputTokens + outputTokens, inputTokens, - cachedInputTokens: usage.cacheReadTokens, + cachedInputTokens, outputTokens, - reasoningOutputTokens: usage.thinkingTokens, - lastInputTokens: inputTokens, - lastCachedInputTokens: usage.cacheReadTokens, - lastOutputTokens: outputTokens, - lastReasoningOutputTokens: usage.thinkingTokens, + reasoningOutputTokens, + lastUsedTokens: lastInputTokens + lastOutputTokens, + lastInputTokens, + lastCachedInputTokens, + lastOutputTokens, + lastReasoningOutputTokens, }; } From 282971cde576a2102dff9c40cc583ffbaf63465a Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sat, 16 May 2026 00:56:48 -0400 Subject: [PATCH 11/11] Align Droid final block dedup keys --- apps/server/src/provider/Layers/DroidAdapter.test.ts | 9 +++++++-- apps/server/src/provider/droid/DroidRuntimeEvents.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 2b2964b0091..97e8adaa2c0 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -417,7 +417,7 @@ it.effect("does not duplicate Droid final create_message text after streaming de type: DroidMessageType.CreateMessage, messageId: "assistant-streamed", role: "assistant", - content: [{ type: "text", text: "stream" }], + content: [{ type: "text", id: "sdk-text-block", text: "stream" }], }, { type: DroidMessageType.TurnComplete, tokenUsage: null }, ], @@ -479,7 +479,12 @@ it.effect("does not duplicate Droid final thinking content after streaming delta messageId: "assistant-thinking", role: "assistant", content: [ - { type: "thinking", signature: "test-signature", thinking: "think" }, + { + type: "thinking", + id: "sdk-thinking-block", + signature: "test-signature", + thinking: "think", + }, { type: "text", text: "answer" }, ], }, diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index 2d76ead29ff..2efd05b60b5 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -90,7 +90,7 @@ export async function handleDroidMessage(input: { if (text.length === 0) { continue; } - const itemId = block.id ?? `${message.messageId}-${index}`; + const itemId = `${message.messageId}-${index}`; if (block.type === "text") { const previousText = context.activeAssistantItems.get(itemId) ?? ""; const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text;