From 45769d5c8d7123683e1de1469adfc15b059419ee Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Mon, 30 Mar 2026 15:29:07 -0400 Subject: [PATCH 1/9] feat(droid): add ACP-backed provider integration Wire Droid through the provider, model-selection, and settings layers so it behaves as a first-class runtime in T3 Code. Normalize ACP tool and subagent events so streamed work reaches the UI with concrete titles and progress instead of collapsing into generic tool placeholders. --- .../server/src/git/Services/TextGeneration.ts | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.ts | 5 + .../src/provider/Layers/ClaudeAdapter.ts | 80 +- .../src/provider/Layers/DroidAdapter.ts | 1148 +++++++++++++++++ .../src/provider/Layers/DroidProvider.ts | 309 +++++ .../Layers/ProviderAdapterRegistry.test.ts | 21 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../src/provider/Layers/ProviderRegistry.ts | 32 +- .../Layers/ProviderSessionDirectory.test.ts | 24 + .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/DroidAdapter.ts | 21 + .../src/provider/Services/DroidProvider.ts | 9 + .../src/provider/toolCallMetadata.test.ts | 16 + apps/server/src/provider/toolCallMetadata.ts | 95 ++ apps/server/src/serverLayers.ts | 2 + apps/web/src/components/ChatView.tsx | 20 +- apps/web/src/components/Icons.tsx | 9 + .../components/KeybindingsToast.browser.tsx | 3 +- .../chat/MessageCopyButton.browser.tsx | 41 + .../components/chat/ProviderModelPicker.tsx | 3 +- apps/web/src/components/chat/TraitsPicker.tsx | 73 +- .../chat/composerProviderRegistry.tsx | 94 +- .../components/settings/SettingsPanels.tsx | 16 +- apps/web/src/composerDraftStore.ts | 92 +- apps/web/src/modelSelection.ts | 59 +- apps/web/src/session-logic.test.ts | 26 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 2 +- packages/contracts/src/model.ts | 10 + packages/contracts/src/orchestration.ts | 17 +- packages/contracts/src/settings.ts | 21 +- packages/shared/src/DrainableWorker.ts | 43 +- packages/shared/src/model.ts | 10 + research.md | 545 ++++++++ 35 files changed, 2656 insertions(+), 200 deletions(-) create mode 100644 apps/server/src/provider/Layers/DroidAdapter.ts create mode 100644 apps/server/src/provider/Layers/DroidProvider.ts create mode 100644 apps/server/src/provider/Services/DroidAdapter.ts create mode 100644 apps/server/src/provider/Services/DroidProvider.ts create mode 100644 apps/server/src/provider/toolCallMetadata.test.ts create mode 100644 apps/server/src/provider/toolCallMetadata.ts create mode 100644 apps/web/src/components/chat/MessageCopyButton.browser.tsx create mode 100644 research.md diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 0df2fff62c..3f6b7a43ae 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "droid"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 529eae2444..2e537b1090 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1967,7 +1967,9 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(toolUpdate?.kind).toBe("tool.updated"); expect(toolUpdatePayload?.itemType).toBe("command_execution"); + expect(toolUpdatePayload?.title).toBe("Run tests"); expect(toolUpdatePayload?.status).toBe("in_progress"); + expect(toolUpdatePayload?.detail).toBe("bun test"); const warning = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-runtime-warning", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b479d1c28a..e81a1d7afe 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -441,6 +441,7 @@ function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -464,7 +465,9 @@ function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -485,7 +488,9 @@ function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.payload.title ? { title: event.payload.title } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6b50bd4fbb..4e81b7dc19 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -64,6 +64,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { classifyToolItemType, summarizeToolRequest, titleForTool } from "../toolCallMetadata.ts"; import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, @@ -394,50 +395,6 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } -function classifyToolItemType(toolName: string): CanonicalItemType { - const normalized = toolName.toLowerCase(); - if (normalized.includes("agent")) { - return "collab_agent_tool_call"; - } - if ( - normalized === "task" || - normalized === "agent" || - normalized.includes("subagent") || - normalized.includes("sub-agent") - ) { - return "collab_agent_tool_call"; - } - if ( - normalized.includes("bash") || - normalized.includes("command") || - normalized.includes("shell") || - normalized.includes("terminal") - ) { - return "command_execution"; - } - if ( - normalized.includes("edit") || - normalized.includes("write") || - normalized.includes("file") || - normalized.includes("patch") || - normalized.includes("replace") || - normalized.includes("create") || - normalized.includes("delete") - ) { - return "file_change"; - } - if (normalized.includes("mcp")) { - return "mcp_tool_call"; - } - if (normalized.includes("websearch") || normalized.includes("web search")) { - return "web_search"; - } - if (normalized.includes("image")) { - return "image_view"; - } - return "dynamic_tool_call"; -} - function isReadOnlyToolName(toolName: string): boolean { const normalized = toolName.toLowerCase(); return ( @@ -462,41 +419,6 @@ function classifyRequestType(toolName: string): CanonicalRequestType { : "dynamic_tool_call"; } -function summarizeToolRequest(toolName: string, input: Record): string { - const commandValue = input.command ?? input.cmd; - const command = typeof commandValue === "string" ? commandValue : undefined; - if (command && command.trim().length > 0) { - return `${toolName}: ${command.trim().slice(0, 400)}`; - } - - const serialized = JSON.stringify(input); - if (serialized.length <= 400) { - return `${toolName}: ${serialized}`; - } - return `${toolName}: ${serialized.slice(0, 397)}...`; -} - -function titleForTool(itemType: CanonicalItemType): string { - switch (itemType) { - case "command_execution": - return "Command run"; - case "file_change": - return "File change"; - case "mcp_tool_call": - return "MCP tool call"; - case "collab_agent_tool_call": - return "Subagent task"; - case "web_search": - return "Web search"; - case "image_view": - return "Image view"; - case "dynamic_tool_call": - return "Tool call"; - default: - return "Item"; - } -} - const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ "image/gif", "image/jpeg", diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts new file mode 100644 index 0000000000..9ae5f85f28 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -0,0 +1,1148 @@ +/** + * DroidAdapterLive - ACP (Agent Client Protocol) provider adapter. + * + * Spawns `droid exec --output-format acp` as a child process per session and + * speaks JSON-RPC 2.0 over stdio. Maps ACP session/update notifications into + * canonical ProviderRuntimeEvent vocabulary. + * + * @module DroidAdapterLive + */ +import { + type CanonicalItemType, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderTurnStartResult, + EventId, + RuntimeItemId, + RuntimeTaskId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Queue, Random, Stream } from "effect"; +import * as ChildProcess from "node:child_process"; +import * as readline from "node:readline"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { DroidAdapter, type DroidAdapterShape } from "../Services/DroidAdapter.ts"; +import { classifyToolItemType, summarizeToolRequest, titleForTool } from "../toolCallMetadata.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +const PROVIDER = "droid" as const; +const ACP_VERSION = 1; +const DROID_PREFERRED_ENABLED_TOOLS = ["task-cli"] as const; + +function getDroidAutoLevel(runtimeMode: ProviderSession["runtimeMode"]): string | undefined { + return runtimeMode === "full-access" ? "high" : undefined; +} + +function getDroidReasoningEffort(input: { + modelSelection?: { provider: string; options?: Record | undefined } | undefined; +}): string | undefined { + if (input.modelSelection?.provider !== PROVIDER) { + return undefined; + } + const effort = input.modelSelection.options?.effort; + return typeof effort === "string" && effort.length > 0 ? effort : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function inferAcpToolName(title: unknown, rawInput: unknown): string { + const input = asRecord(rawInput); + const candidates = [ + title, + input?.toolName, + input?.tool_name, + input?.name, + input?.tool, + input?.type, + ]; + + for (const candidate of candidates) { + const value = asTrimmedString(candidate); + if (value) { + return value; + } + } + + if (asTrimmedString(input?.subagent_type)) { + return "Task"; + } + + return "Tool"; +} + +function summarizeAcpToolInput(toolName: string, rawInput: unknown): string | undefined { + const input = asRecord(rawInput); + if (input) { + return summarizeToolRequest(toolName, input); + } + + if (rawInput === undefined) { + return undefined; + } + + const serialized = JSON.stringify(rawInput); + if (!serialized || serialized === "{}") { + return undefined; + } + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +function taskDescriptionFromToolInput( + toolName: string, + rawInput: unknown, + detail?: string, +): string { + const input = asRecord(rawInput); + const description = + asTrimmedString(input?.description) ?? + asTrimmedString(input?.prompt) ?? + asTrimmedString(input?.instructions) ?? + detail ?? + `${titleForTool(classifyToolItemType(toolName, input))} in progress`; + return description; +} + +function collectAcpToolOutputDeltas( + rawOutput: unknown, + content: ReadonlyArray | undefined, +): string[] { + const deltas: string[] = []; + + const rawOutputText = asTrimmedString(asRecord(rawOutput)?.text); + if (rawOutputText) { + deltas.push(rawOutputText); + } + + for (const chunk of content ?? []) { + const chunkRecord = asRecord(chunk); + if (!chunkRecord) { + continue; + } + const directText = asTrimmedString(chunkRecord.text); + if (directText) { + deltas.push(directText); + continue; + } + const nestedText = asTrimmedString(asRecord(chunkRecord.content)?.text); + if (nestedText) { + deltas.push(nestedText); + } + } + + return deltas; +} + +function toolCallKey(threadId: ThreadId, toolCallId: string): string { + return `${threadId}:tc:${toolCallId}`; +} + +function toolCallData(toolName: string, input: unknown): { toolName: string; input?: unknown } { + return { + toolName, + ...(input !== undefined ? { input } : {}), + }; +} + +interface AcpToolCallState { + readonly key: string; + readonly threadId: ThreadId; + readonly turnId: TurnId; + readonly itemId: RuntimeItemId; + readonly taskId: RuntimeTaskId | null; + readonly itemType: CanonicalItemType; + readonly title: string; + readonly toolName: string; + readonly input: unknown; + readonly taskDescription: string; + detail?: string; + lastTaskSummary?: string; +} + +// ── JSON-RPC helpers ──────────────────────────────────────────────── + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { + return "id" in msg && !("method" in msg); +} + +function isNotification(msg: JsonRpcMessage): msg is JsonRpcNotification { + return "method" in msg && !("id" in msg); +} + +// ── ACP Session State ─────────────────────────────────────────────── + +interface AcpSessionState { + readonly threadId: ThreadId; + readonly process: ChildProcess.ChildProcess; + readonly rl: readline.Interface; + readonly pendingRequests: Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } + >; + nextId: number; + acpSessionId: string | null; + activeTurnId: TurnId | null; + status: ProviderSession["status"]; + cwd: string | undefined; + model: string | undefined; + createdAt: string; +} + +function sendMessage(session: AcpSessionState, msg: JsonRpcRequest | JsonRpcNotification): void { + const data = JSON.stringify(msg); + session.process.stdin?.write(data + "\n"); +} + +function sendRequest(session: AcpSessionState, method: string, params?: unknown): Promise { + const id = session.nextId++; + return new Promise((resolve, reject) => { + session.pendingRequests.set(id, { resolve, reject }); + sendMessage(session, { jsonrpc: "2.0", id, method, params }); + }); +} + +function sendNotification(session: AcpSessionState, method: string, params?: unknown): void { + sendMessage(session, { jsonrpc: "2.0", method, params }); +} + +// ── Layer Implementation ──────────────────────────────────────────── + +export const DroidAdapterLive = Layer.effect( + DroidAdapter, + Effect.gen(function* () { + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const nextTurnId = Effect.map(Random.nextUUIDv4, (id) => TurnId.makeUnsafe(id)); + const nextItemId = Effect.map(Random.nextUUIDv4, (id) => RuntimeItemId.makeUnsafe(id)); + const makeStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const getSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const session = sessions.get(threadId); + if (!session) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(session); + }; + + const runtimeEvent = (obj: Record): ProviderRuntimeEvent => + obj as unknown as ProviderRuntimeEvent; + + const emitSessionStarted = (threadId: ThreadId) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "session.started", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + createdAt: stamp.createdAt, + payload: {}, + }), + ); + }); + + const emitTurnStarted = (threadId: ThreadId, turnId: TurnId, model?: string, effort?: string) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "turn.started", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + }), + ); + }); + + const emitTurnCompleted = ( + threadId: ThreadId, + turnId: TurnId, + state: "completed" | "failed", + errorMessage?: string, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + state, + ...(errorMessage ? { errorMessage } : {}), + }, + }), + ); + }); + + const emitContentDelta = ( + threadId: ThreadId, + turnId: TurnId, + itemId: RuntimeItemId, + text: string, + streamKind: string, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "content.delta", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + itemId, + createdAt: stamp.createdAt, + payload: { + streamKind, + delta: text, + }, + }), + ); + }); + + const emitItemStarted = ( + threadId: ThreadId, + turnId: TurnId, + itemId: RuntimeItemId, + itemType: string, + metadata?: { + title?: string; + detail?: string; + data?: unknown; + }, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "item.started", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + itemId, + createdAt: stamp.createdAt, + payload: { + itemType, + status: "inProgress", + ...(metadata?.title ? { title: metadata.title } : {}), + ...(metadata?.detail ? { detail: metadata.detail } : {}), + ...(metadata?.data !== undefined ? { data: metadata.data } : {}), + }, + }), + ); + }); + + const emitItemUpdated = ( + threadId: ThreadId, + turnId: TurnId, + itemId: RuntimeItemId, + itemType: string, + metadata?: { + title?: string; + detail?: string; + data?: unknown; + }, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + itemId, + createdAt: stamp.createdAt, + payload: { + itemType, + status: "inProgress", + ...(metadata?.title ? { title: metadata.title } : {}), + ...(metadata?.detail ? { detail: metadata.detail } : {}), + ...(metadata?.data !== undefined ? { data: metadata.data } : {}), + }, + }), + ); + }); + + const emitItemCompleted = ( + threadId: ThreadId, + turnId: TurnId, + itemId: RuntimeItemId, + itemType: string, + status: "completed" | "failed", + metadata?: { + title?: string; + detail?: string; + data?: unknown; + }, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "item.completed", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + itemId, + createdAt: stamp.createdAt, + payload: { + itemType, + status, + ...(metadata?.title ? { title: metadata.title } : {}), + ...(metadata?.detail ? { detail: metadata.detail } : {}), + ...(metadata?.data !== undefined ? { data: metadata.data } : {}), + }, + }), + ); + }); + + const emitTaskStarted = ( + threadId: ThreadId, + turnId: TurnId, + taskId: RuntimeTaskId, + description: string, + taskType?: string, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "task.started", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + taskId, + description, + ...(taskType ? { taskType } : {}), + }, + }), + ); + }); + + const emitTaskProgress = ( + threadId: ThreadId, + turnId: TurnId, + taskId: RuntimeTaskId, + description: string, + summary: string, + lastToolName?: string, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "task.progress", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + taskId, + description, + summary, + ...(lastToolName ? { lastToolName } : {}), + }, + }), + ); + }); + + const emitTaskCompleted = ( + threadId: ThreadId, + turnId: TurnId, + taskId: RuntimeTaskId, + status: "completed" | "failed", + summary?: string, + ) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + taskId, + status, + ...(summary ? { summary } : {}), + }, + }), + ); + }); + + const emitSessionExited = (threadId: ThreadId, reason?: string) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + createdAt: stamp.createdAt, + payload: { + ...(reason ? { reason } : {}), + exitKind: "graceful", + }, + }), + ); + }); + + const emitRuntimeError = (threadId: ThreadId, turnId: TurnId | undefined, message: string) => + Effect.gen(function* () { + const stamp = yield* makeStamp(); + yield* offerEvent( + runtimeEvent({ + type: "runtime.error", + eventId: stamp.eventId, + provider: PROVIDER, + threadId, + turnId, + createdAt: stamp.createdAt, + payload: { + class: "provider_error", + message, + }, + }), + ); + }); + + // ── ACP session/update notification handler ───────────────────── + + const runtimeItemIds = new Map(); + const toolCalls = new Map(); + + const emitToolCallCompletion = ( + state: AcpToolCallState, + status: "completed" | "failed", + summary?: string, + ) => + Effect.gen(function* () { + const detail = summary ?? state.detail; + yield* emitItemCompleted( + state.threadId, + state.turnId, + state.itemId, + state.itemType, + status, + { + title: state.title, + ...(detail ? { detail } : {}), + data: toolCallData(state.toolName, state.input), + }, + ); + if (state.taskId) { + yield* emitTaskCompleted( + state.threadId, + state.turnId, + state.taskId, + status, + summary ?? state.lastTaskSummary, + ); + } + toolCalls.delete(state.key); + }); + + const closeOpenToolCallsForTurn = ( + threadId: ThreadId, + turnId: TurnId, + status: "completed" | "failed", + ) => + Effect.forEach( + Array.from(toolCalls.values()).filter( + (state) => state.threadId === threadId && state.turnId === turnId, + ), + (state) => emitToolCallCompletion(state, status), + { discard: true }, + ); + + const handleSessionUpdate = (session: AcpSessionState, params: Record) => + Effect.gen(function* () { + const threadId = session.threadId; + const turnId = session.activeTurnId; + if (!turnId) return; + + const update = asRecord(params.update); + if (!update) return; + + const kind = asTrimmedString(update.sessionUpdate); + + yield* Effect.logDebug("[DroidAdapter] session/update", { + kind, + threadId, + turnId, + ts: new Date().toISOString(), + ...(kind === "tool_call" || kind === "tool_call_update" + ? { + toolCallId: update.toolCallId ?? "", + status: update.status ?? "", + title: update.title ?? "", + } + : {}), + ...(kind === "agent_message_chunk" + ? { + contentType: asRecord(update.content)?.type, + textLen: asTrimmedString(asRecord(update.content)?.text)?.length ?? 0, + } + : {}), + }); + + if (kind === "agent_message_chunk") { + const content = asRecord(update.content); + const contentType = asTrimmedString(content?.type); + const text = asTrimmedString(content?.text); + if (contentType === "text" && text) { + const key = `${threadId}:${turnId}:assistant`; + let itemId = runtimeItemIds.get(key); + if (!itemId) { + itemId = yield* nextItemId; + runtimeItemIds.set(key, itemId); + yield* emitItemStarted(threadId, turnId, itemId, "assistant_message"); + } + yield* emitContentDelta(threadId, turnId, itemId, text, "assistant_text"); + } else if (contentType === "thinking" && text) { + const key = `${threadId}:${turnId}:thinking`; + let itemId = runtimeItemIds.get(key); + if (!itemId) { + itemId = yield* nextItemId; + runtimeItemIds.set(key, itemId); + yield* emitItemStarted(threadId, turnId, itemId, "reasoning"); + } + yield* emitContentDelta(threadId, turnId, itemId, text, "reasoning_text"); + } + } else if (kind === "tool_call") { + const tcId = asTrimmedString(update.toolCallId) ?? (yield* Random.nextUUIDv4); + const key = toolCallKey(threadId, tcId); + const rawInput = update.rawInput; + const inputRecord = asRecord(rawInput); + const toolName = inferAcpToolName(update.title, rawInput); + const itemType = classifyToolItemType(toolName, inputRecord); + const title = titleForTool(itemType); + const detail = summarizeAcpToolInput(toolName, rawInput); + + let state = toolCalls.get(key); + if (!state) { + const itemId = yield* nextItemId; + const taskId = + itemType === "collab_agent_tool_call" ? RuntimeTaskId.makeUnsafe(tcId) : null; + state = { + key, + threadId, + turnId, + itemId, + taskId, + itemType, + title, + toolName, + input: rawInput, + taskDescription: taskDescriptionFromToolInput(toolName, rawInput, detail), + ...(detail ? { detail } : {}), + } satisfies AcpToolCallState; + toolCalls.set(key, state); + runtimeItemIds.set(key, itemId); + yield* emitItemStarted(threadId, turnId, itemId, itemType, { + title, + ...(detail ? { detail } : {}), + data: toolCallData(toolName, rawInput), + }); + if (taskId) { + yield* emitTaskStarted( + threadId, + turnId, + taskId, + state.taskDescription, + asTrimmedString(inputRecord?.subagent_type), + ); + } + } + + if (update.status === "completed" || update.status === "failed") { + yield* emitToolCallCompletion(state, update.status); + runtimeItemIds.delete(key); + } + } else if (kind === "tool_call_update") { + const tcId = asTrimmedString(update.toolCallId); + if (!tcId) { + return; + } + const key = toolCallKey(threadId, tcId); + const state = toolCalls.get(key); + if (!state) { + return; + } + + const deltas = collectAcpToolOutputDeltas( + update.rawOutput, + Array.isArray(update.content) ? update.content : undefined, + ); + const summary = deltas.join("\n"); + if (summary.length > 0) { + state.detail = summary; + yield* emitItemUpdated(threadId, turnId, state.itemId, state.itemType, { + title: state.title, + detail: summary, + data: toolCallData(state.toolName, state.input), + }); + if (state.taskId) { + state.lastTaskSummary = summary; + yield* emitTaskProgress( + threadId, + turnId, + state.taskId, + state.taskDescription, + summary, + state.toolName, + ); + } + } + + if (update.status === "completed" || update.status === "failed") { + yield* emitToolCallCompletion(state, update.status, summary || undefined); + runtimeItemIds.delete(key); + } + } else if (kind === "status" || kind === "error") { + const status = update as { status?: string; message?: string }; + if (status.message) { + yield* emitRuntimeError(threadId, turnId, status.message); + } + } + }); + + // ── Wire incoming messages from ACP process ───────────────────── + + const wireProcessMessages = (session: AcpSessionState) => { + session.rl.on("line", (line: string) => { + if (!line.trim()) return; + let msg: JsonRpcMessage; + try { + msg = JSON.parse(line) as JsonRpcMessage; + } catch { + return; + } + + if (isResponse(msg)) { + const pending = session.pendingRequests.get(msg.id); + if (pending) { + session.pendingRequests.delete(msg.id); + if (msg.error) { + pending.reject(new Error(msg.error.message)); + } else { + pending.resolve(msg.result); + } + } + } else if (isNotification(msg)) { + if (msg.method === "session/update") { + const params = (msg.params ?? {}) as Record; + Effect.runPromise(handleSessionUpdate(session, params)).catch(() => {}); + } else { + Effect.runPromise( + Effect.logDebug("[DroidAdapter] unhandled notification", { + method: msg.method, + threadId: session.threadId, + }), + ).catch(() => {}); + } + } else { + Effect.runPromise( + Effect.logDebug("[DroidAdapter] unhandled message", { + hasMethod: "method" in msg, + hasId: "id" in msg, + threadId: session.threadId, + }), + ).catch(() => {}); + } + }); + + session.process.on("exit", () => { + session.status = "closed"; + for (const [, pending] of session.pendingRequests) { + pending.reject(new Error("ACP process exited")); + } + session.pendingRequests.clear(); + Effect.runPromise(emitSessionExited(session.threadId)).catch(() => {}); + }); + + session.process.stderr?.on("data", () => {}); + }; + + // ── ACP initialization handshake ──────────────────────────────── + + const initializeAcp = (session: AcpSessionState) => + Effect.tryPromise({ + try: async () => { + const result = (await sendRequest(session, "initialize", { + protocolVersion: ACP_VERSION, + client_info: { name: "t3-code", version: "0.1.0" }, + capabilities: {}, + })) as Record | undefined; + sendNotification(session, "initialized"); + return result; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: session.threadId, + detail: `ACP initialization failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const createAcpSession = (session: AcpSessionState, cwd: string) => + Effect.tryPromise({ + try: async () => { + const result = (await sendRequest(session, "session/new", { + cwd, + mcpServers: [], + })) as { sessionId?: string } | undefined; + session.acpSessionId = result?.sessionId ?? null; + return result; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + // ── Adapter interface ─────────────────────────────────────────── + + const startSession: DroidAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const settings = yield* serverSettingsService.getSettings.pipe(Effect.orDie); + const binaryPath = settings.providers.droid.binaryPath; + const cwd = input.cwd ?? process.cwd(); + + const model = + input.modelSelection?.provider === PROVIDER ? input.modelSelection.model : undefined; + const reasoningEffort = getDroidReasoningEffort(input); + const autoLevel = getDroidAutoLevel(input.runtimeMode); + const args = ["exec", "--output-format", "acp"]; + if (model) { + args.push("--model", model); + } + if (reasoningEffort) { + args.push("--reasoning-effort", reasoningEffort); + } + if (autoLevel) { + args.push("--auto", autoLevel); + } + if (autoLevel === "high") { + args.push("--enabled-tools", DROID_PREFERRED_ENABLED_TOOLS.join(",")); + } + const child = ChildProcess.spawn(binaryPath, args, { + stdio: ["pipe", "pipe", "pipe"], + cwd, + env: { ...process.env }, + }); + + if (!child.stdin || !child.stdout) { + child.kill(); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to spawn Droid ACP process: missing stdio", + }); + } + + const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const now = new Date().toISOString(); + const session: AcpSessionState = { + threadId: input.threadId, + process: child, + rl, + pendingRequests: new Map(), + nextId: 1, + acpSessionId: null, + activeTurnId: null, + status: "connecting", + cwd, + model: input.modelSelection?.model, + createdAt: now, + }; + + sessions.set(input.threadId, session); + wireProcessMessages(session); + + yield* initializeAcp(session); + yield* createAcpSession(session, cwd); + + session.status = "ready"; + yield* emitSessionStarted(input.threadId); + + return { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode ?? "full-access", + cwd, + model: input.modelSelection?.model, + threadId: input.threadId, + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }); + + const sendTurn: DroidAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const session = yield* getSession(input.threadId); + const turnId = yield* nextTurnId; + session.activeTurnId = turnId; + session.status = "running"; + + yield* emitTurnStarted( + input.threadId, + turnId, + session.model, + getDroidReasoningEffort(input), + ); + + // Build prompt content blocks + const promptBlocks: Array<{ type: string; text?: string }> = []; + if (input.input) { + promptBlocks.push({ type: "text", text: input.input }); + } + + // Fire session/prompt asynchronously -- the response signals turn completion + Effect.runPromise( + Effect.tryPromise({ + try: async () => { + const promptStart = Date.now(); + await sendRequest(session, "session/prompt", { + sessionId: session.acpSessionId, + prompt: promptBlocks, + }); + const elapsed = ((Date.now() - promptStart) / 1000).toFixed(1); + await Effect.runPromise( + Effect.logDebug("[DroidAdapter] session/prompt resolved", { + threadId: session.threadId, + turnId, + elapsedSec: elapsed, + }), + ); + + // Complete assistant message item if still open + const assistantKey = `${session.threadId}:${turnId}:assistant`; + const assistantItemId = runtimeItemIds.get(assistantKey); + if (assistantItemId) { + await Effect.runPromise( + emitItemCompleted( + session.threadId, + turnId, + assistantItemId, + "assistant_message", + "completed", + ), + ); + runtimeItemIds.delete(assistantKey); + } + + // Complete thinking item if still open + const thinkingKey = `${session.threadId}:${turnId}:thinking`; + const thinkingItemId = runtimeItemIds.get(thinkingKey); + if (thinkingItemId) { + await Effect.runPromise( + emitItemCompleted( + session.threadId, + turnId, + thinkingItemId, + "reasoning", + "completed", + ), + ); + runtimeItemIds.delete(thinkingKey); + } + + await Effect.runPromise( + closeOpenToolCallsForTurn(session.threadId, turnId, "completed"), + ); + + session.activeTurnId = null; + session.status = "ready"; + await Effect.runPromise(emitTurnCompleted(session.threadId, turnId, "completed")); + }, + catch: async (cause) => { + await Effect.runPromise( + closeOpenToolCallsForTurn(session.threadId, turnId, "failed"), + ); + session.activeTurnId = null; + session.status = "ready"; + const message = cause instanceof Error ? cause.message : String(cause); + await Effect.runPromise(emitRuntimeError(session.threadId, turnId, message)); + await Effect.runPromise( + emitTurnCompleted(session.threadId, turnId, "failed", message), + ); + }, + }), + ).catch(() => {}); + + return { + threadId: input.threadId, + turnId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: DroidAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const session = yield* getSession(threadId); + if (session.acpSessionId) { + yield* Effect.tryPromise({ + try: () => + sendRequest(session, "session/cancel", { + sessionId: session.acpSessionId, + }), + catch: () => undefined, + }).pipe(Effect.ignore); + } + }); + + const respondToRequest: DroidAdapterShape["respondToRequest"] = () => Effect.void; + + const respondToUserInput: DroidAdapterShape["respondToUserInput"] = () => Effect.void; + + const stopSession: DroidAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const session = sessions.get(threadId); + if (!session) return; + session.rl.close(); + session.process.kill(); + session.status = "closed"; + sessions.delete(threadId); + yield* emitSessionExited(threadId); + }); + + const listSessions: DroidAdapterShape["listSessions"] = () => + Effect.sync(() => { + const now = new Date().toISOString(); + return Array.from(sessions.values()).map( + (s): ProviderSession => + Object.assign( + { + provider: PROVIDER, + status: s.status, + runtimeMode: "full-access" as const, + cwd: s.cwd, + model: s.model, + threadId: s.threadId, + createdAt: s.createdAt, + updatedAt: now, + }, + s.activeTurnId ? { activeTurnId: s.activeTurnId } : {}, + ), + ); + }); + + const hasSession: DroidAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: DroidAdapterShape["readThread"] = (threadId) => + Effect.succeed({ threadId, turns: [] }); + + const rollbackThread: DroidAdapterShape["rollbackThread"] = (threadId) => + Effect.succeed({ threadId, turns: [] }); + + const stopAll: DroidAdapterShape["stopAll"] = () => + Effect.sync(() => { + for (const [, session] of sessions) { + session.rl.close(); + session.process.kill(); + session.status = "closed"; + } + sessions.clear(); + }); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "restart-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, + } 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 0000000000..ded06177e6 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -0,0 +1,309 @@ +import type { DroidSettings, ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + DEFAULT_TIMEOUT_MS, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { DroidProvider } from "../Services/DroidProvider"; +import { ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "droid" as const; +const FALLBACK_MODELS: ReadonlyArray = [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, capabilities: null }, +]; + +const MODEL_LINE_RE = /^\s{2}(\S+)\s{2,}(.+)$/; +const MODEL_DETAILS_LINE_RE = + /^\s*-\s(.+?): supports reasoning: (Yes|No); supported: \[([^\]]*)\]; default: (\S+)$/; + +function toEffortLabel(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + case "none": + case "off": + return "Off"; + default: + return value + .split("-") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); + } +} + +function parseCapabilitiesFromHelp(helpText: string): Map { + const capabilitiesByName = new Map(); + let inSection = false; + + for (const line of helpText.split("\n")) { + if (line.startsWith("Model details:")) { + inSection = true; + continue; + } + if (!inSection) continue; + if (line.trim() === "") continue; + if (!line.trimStart().startsWith("- ")) { + break; + } + + const match = MODEL_DETAILS_LINE_RE.exec(line); + if (!match?.[1] || !match[2] || !match[3] || !match[4]) continue; + + const name = match[1].trim(); + const supportsReasoning = match[2] === "Yes"; + const supportedValues = match[3] + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const defaultValue = match[4].trim(); + + capabilitiesByName.set(name, { + reasoningEffortLevels: supportsReasoning + ? supportedValues.map((value) => + value === defaultValue + ? { value, label: toEffortLabel(value), isDefault: true } + : { value, label: toEffortLabel(value) }, + ) + : [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + } + + return capabilitiesByName; +} + +function normalizeCapabilityKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function resolveCapabilitiesForModel( + slug: string, + name: string, + capabilitiesByName: Map, +): ModelCapabilities | null { + const direct = capabilitiesByName.get(name); + if (direct) { + return direct; + } + + const normalizedSlug = normalizeCapabilityKey(slug.replace(/^custom:/, "")); + const normalizedName = normalizeCapabilityKey(name); + + for (const [candidateName, capabilities] of capabilitiesByName.entries()) { + const normalizedCandidate = normalizeCapabilityKey(candidateName); + if ( + normalizedCandidate === normalizedName || + normalizedCandidate === normalizedSlug || + normalizedName.includes(normalizedCandidate) || + normalizedCandidate.includes(normalizedName) || + normalizedSlug.includes(normalizedCandidate) || + normalizedCandidate.includes(normalizedSlug) + ) { + return capabilities; + } + } + + return null; +} + +function parseModelsFromHelp(helpText: string): ReadonlyArray { + const models: ServerProviderModel[] = []; + const capabilitiesByName = parseCapabilitiesFromHelp(helpText); + let inSection = false; + + for (const line of helpText.split("\n")) { + if (line.startsWith("Available Models:")) { + inSection = true; + continue; + } + if (line.startsWith("Custom Models:")) { + inSection = true; + continue; + } + if (inSection && line.trim() === "") { + inSection = false; + continue; + } + if (!inSection) continue; + + const match = MODEL_LINE_RE.exec(line); + if (!match?.[1] || !match[2]) continue; + const slug = match[1]; + let name = match[2].trim(); + if (name.includes("[Deprecated]")) continue; + if (name.includes("(default)")) name = name.replace("(default)", "").trim(); + models.push({ + slug, + name, + isCustom: slug.startsWith("custom:"), + capabilities: resolveCapabilitiesForModel(slug, name, capabilitiesByName), + }); + } + + return models.length > 0 ? models : FALLBACK_MODELS; +} + +const runDroidCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const droidSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.droid), + ); + const command = ChildProcess.make(droidSettings.binaryPath, [...args], { + shell: process.platform === "win32", + }); + return yield* spawnAndCollect(droidSettings.binaryPath, command); + }); + +export const checkDroidProviderStatus = Effect.gen(function* () { + const droidSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.droid), + ); + const checkedAt = new Date().toISOString(); + const disabledModels = providerModelsFromSettings( + FALLBACK_MODELS, + PROVIDER, + droidSettings.customModels, + ); + + if (!droidSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: disabledModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Droid is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runDroidCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: droidSettings.enabled, + checkedAt, + models: disabledModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Droid CLI (`droid`) is not installed or not on PATH." + : `Failed to execute Droid CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: droidSettings.enabled, + checkedAt, + models: disabledModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Droid CLI is installed but timed out while running.", + }, + }); + } + + const version = Option.getOrThrow(versionProbe.success); + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: droidSettings.enabled, + checkedAt, + models: disabledModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: detail + ? `Droid CLI is installed but failed to run. ${detail}` + : "Droid CLI is installed but failed to run.", + }, + }); + } + + const helpProbe = yield* runDroidCommand(["exec", "--help"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + let builtInModels: ReadonlyArray = FALLBACK_MODELS; + if (Result.isSuccess(helpProbe) && Option.isSome(helpProbe.success)) { + const helpResult = Option.getOrThrow(helpProbe.success); + builtInModels = parseModelsFromHelp(`${helpResult.stdout}\n${helpResult.stderr}`); + } + + const models = providerModelsFromSettings(builtInModels, PROVIDER, droidSettings.customModels); + + return buildServerProvider({ + provider: PROVIDER, + enabled: droidSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const DroidProviderLive = Layer.effect( + DroidProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkDroidProviderStatus.pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.droid), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.droid), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..007d05ed82 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { DroidAdapter, DroidAdapterShape } from "../Services/DroidAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeDroidAdapter: DroidAdapterShape = { + provider: "droid", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(DroidAdapter, fakeDroidAdapter), ), ), NodeServices.layer, @@ -68,7 +87,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "droid"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 23ef8d1b9b..9822089db2 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { DroidAdapter } from "../Services/DroidAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -27,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* DroidAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1e66ce8ff5..fd8c856c2b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -8,17 +8,21 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { DroidProviderLive } from "./DroidProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; +import type { DroidProviderShape } from "../Services/DroidProvider"; +import { DroidProvider } from "../Services/DroidProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + droidProvider: DroidProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, droidProvider.getSnapshot], { concurrency: "unbounded", }); @@ -32,18 +36,19 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const droidProvider = yield* DroidProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(codexProvider, claudeProvider, droidProvider), ); const syncProviders = (options?: { readonly publish?: boolean }) => Effect.gen(function* () { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders(codexProvider, claudeProvider, droidProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -59,6 +64,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(droidProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); return { getProviders: syncProviders({ publish: false }).pipe( @@ -74,10 +82,14 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "droid": + yield* droidProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, droidProvider.refresh], + { concurrency: "unbounded" }, + ); break; } return yield* syncProviders(); @@ -90,4 +102,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(DroidProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index d23b247f21..6408aeddd6 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -163,6 +163,30 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("reads persisted droid bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const threadId = ThreadId.makeUnsafe("thread-droid"); + + yield* runtimeRepository.upsert({ + threadId, + providerName: "droid", + adapterKey: "droid", + runtimeMode: "full-access", + status: "running", + lastSeenAt: new Date().toISOString(), + resumeCursor: null, + runtimePayload: null, + }); + + const binding = yield* directory.getBinding(threadId); + assertSome(binding, { + threadId, + provider: "droid", + }); + })); + it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d696..75f4b455aa 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "droid") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/DroidAdapter.ts b/apps/server/src/provider/Services/DroidAdapter.ts new file mode 100644 index 0000000000..9d845a6a3e --- /dev/null +++ b/apps/server/src/provider/Services/DroidAdapter.ts @@ -0,0 +1,21 @@ +/** + * DroidAdapter - Droid/ACP implementation of the generic provider adapter contract. + * + * This service owns ACP protocol semantics (JSON-RPC 2.0 over stdio) and emits + * canonical provider runtime events. It does not perform cross-provider routing, + * shared event fan-out, or checkpoint orchestration. + * + * @module DroidAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface DroidAdapterShape extends ProviderAdapterShape { + readonly provider: "droid"; +} + +export class DroidAdapter extends ServiceMap.Service()( + "t3/provider/Services/DroidAdapter", +) {} diff --git a/apps/server/src/provider/Services/DroidProvider.ts b/apps/server/src/provider/Services/DroidProvider.ts new file mode 100644 index 0000000000..364ebb7563 --- /dev/null +++ b/apps/server/src/provider/Services/DroidProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface DroidProviderShape extends ServerProviderShape {} + +export class DroidProvider extends ServiceMap.Service()( + "t3/provider/Services/DroidProvider", +) {} diff --git a/apps/server/src/provider/toolCallMetadata.test.ts b/apps/server/src/provider/toolCallMetadata.test.ts new file mode 100644 index 0000000000..794d49ab50 --- /dev/null +++ b/apps/server/src/provider/toolCallMetadata.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { classifyToolItemType, summarizeToolRequest, titleForTool } from "./toolCallMetadata.ts"; + +describe("toolCallMetadata", () => { + it("classifies task-style tool calls with subagent metadata as collaboration agents", () => { + expect(classifyToolItemType("Task", { subagent_type: "code-reviewer" })).toBe( + "collab_agent_tool_call", + ); + expect(titleForTool("collab_agent_tool_call")).toBe("Subagent task"); + }); + + it("summarizes command-style tool requests using the command text", () => { + expect(summarizeToolRequest("Bash", { command: "git status" })).toBe("Bash: git status"); + }); +}); diff --git a/apps/server/src/provider/toolCallMetadata.ts b/apps/server/src/provider/toolCallMetadata.ts new file mode 100644 index 0000000000..f91ea5d430 --- /dev/null +++ b/apps/server/src/provider/toolCallMetadata.ts @@ -0,0 +1,95 @@ +import type { CanonicalItemType } from "@t3tools/contracts"; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function classifyToolItemType( + toolName: string, + input?: Record, +): CanonicalItemType { + if (asTrimmedString(input?.subagent_type)) { + return "collab_agent_tool_call"; + } + + const normalized = toolName.toLowerCase(); + if (normalized.includes("agent")) { + return "collab_agent_tool_call"; + } + if ( + normalized === "task" || + normalized === "agent" || + normalized.includes("subagent") || + normalized.includes("sub-agent") + ) { + return "collab_agent_tool_call"; + } + if ( + normalized.includes("bash") || + normalized.includes("command") || + normalized.includes("shell") || + normalized.includes("terminal") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("websearch") || normalized.includes("web search")) { + return "web_search"; + } + if (normalized.includes("image")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +export function summarizeToolRequest(toolName: string, input: Record): string { + const commandValue = input.command ?? input.cmd; + const command = typeof commandValue === "string" ? commandValue : undefined; + if (command && command.trim().length > 0) { + return `${toolName}: ${command.trim().slice(0, 400)}`; + } + + const serialized = JSON.stringify(input); + if (serialized.length <= 400) { + return `${toolName}: ${serialized}`; + } + return `${toolName}: ${serialized.slice(0, 397)}...`; +} + +export function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "collab_agent_tool_call": + return "Subagent task"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f..edf8634b3f 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus" import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { DroidAdapterLive } from "./provider/Layers/DroidAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -81,6 +82,7 @@ export function makeServerProviderLayer(): Layer.Layer< const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(DroidAdapterLive), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..df644e9707 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -121,7 +121,7 @@ import { resolveSelectableProvider, } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; +import { buildModelSelection, resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -655,11 +655,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const selectedModelSelection = useMemo( - () => ({ - provider: selectedProvider, - model: selectedModel, - ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), - }), + () => buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedModelForPicker = selectedModel; @@ -1032,6 +1028,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + droid: providerStatuses.find((provider) => provider.provider === "droid")?.models ?? [], }), [providerStatuses], ); @@ -2644,14 +2641,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: selectedProvider, - model: - selectedModel || + const threadCreateModelSelection = buildModelSelection( + selectedProvider, + selectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), - }; + selectedModelSelection.options, + ); if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7d210fa173..6cd86fc43e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -296,6 +296,15 @@ export const AntigravityIcon: Icon = (props) => ( ); +export const DroidIcon: Icon = (props) => ( + + + +); + export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba21af4b77..b4e62065ce 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -57,12 +57,13 @@ function createBaseServerConfig(): ServerConfig { ], availableEditors: [], settings: { - enableAssistantStreaming: false, + enableAssistantStreaming: true, defaultThreadEnvMode: "local" as const, textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + droid: { enabled: false, binaryPath: "", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/MessageCopyButton.browser.tsx b/apps/web/src/components/chat/MessageCopyButton.browser.tsx new file mode 100644 index 0000000000..050b43b82f --- /dev/null +++ b/apps/web/src/components/chat/MessageCopyButton.browser.tsx @@ -0,0 +1,41 @@ +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { MessageCopyButton } from "./MessageCopyButton"; + +describe("MessageCopyButton", () => { + afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + it("falls back to execCommand when the Clipboard API rejects", async () => { + const originalClipboard = navigator.clipboard; + const originalExecCommand = document.execCommand.bind(document); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error("denied")), + }, + }); + const execCommand = vi.fn().mockReturnValue(true); + document.execCommand = execCommand; + + try { + await render(); + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + } finally { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + document.execCommand = originalExecCommand; + } + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 565a9d399d..346389f058 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,7 +18,7 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DroidIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { getProviderSnapshot } from "../../providerModels"; @@ -33,6 +33,7 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, + droid: DroidIcon, cursor: CursorIcon, }; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad53..1371f03bf6 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,6 +1,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, + type DroidModelOptions, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, @@ -52,6 +53,9 @@ function getRawEffort( if (provider === "codex") { return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); } + if (provider === "droid") { + return trimOrNull((modelOptions as DroidModelOptions | undefined)?.effort); + } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } @@ -73,9 +77,30 @@ function buildNextOptions( if (provider === "codex") { return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; } + if (provider === "droid") { + return { ...(modelOptions as DroidModelOptions | undefined), ...patch } as DroidModelOptions; + } return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; } +function hasVisibleTraitSections(input: { + effortLevels: ReadonlyArray<{ value: string; label: string; isDefault?: boolean | undefined }>; + thinkingEnabled: boolean | null; + supportsFastMode: boolean; + contextWindowOptions: ReadonlyArray<{ + value: string; + label: string; + isDefault?: boolean | undefined; + }>; +}): boolean { + return ( + input.effortLevels.length > 0 || + input.thinkingEnabled !== null || + input.supportsFastMode || + input.contextWindowOptions.length > 1 + ); +} + function getSelectedTraits( provider: ProviderKind, models: ReadonlyArray, @@ -138,6 +163,31 @@ function getSelectedTraits( }; } +export function shouldRenderTraitsPicker(input: { + provider: ProviderKind; + models: ReadonlyArray; + model: string | null | undefined; + prompt: string; + modelOptions: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; +}): boolean { + const { effortLevels, thinkingEnabled, caps, contextWindowOptions } = getSelectedTraits( + input.provider, + input.models, + input.model, + input.prompt, + input.modelOptions, + input.allowPromptInjectedEffort ?? true, + ); + + return hasVisibleTraitSections({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }); +} + export interface TraitsMenuContentProps { provider: ProviderKind; models: ReadonlyArray; @@ -221,7 +271,14 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { + if ( + !hasVisibleTraitSections({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }) + ) { return null; } @@ -366,6 +423,16 @@ export const TraitsPicker = memo(function TraitsPicker({ .join(" · "); const isCodexStyle = provider === "codex"; + const hasVisibleTraits = hasVisibleTraitSections({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }); + + if (!hasVisibleTraits) { + return null; + } return ( {isCodexStyle ? ( - {triggerLabel} + {triggerLabel || "Traits"} ) : ( <> - {triggerLabel} + {triggerLabel || "Traits"}