diff --git a/README.md b/README.md index 8ec101387f67..478c9b0a0fbb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Factory Droid, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,11 +13,12 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Factory Droid, Grok Build, and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` +> - Factory Droid: run `curl -fsSL https://app.factory.ai/cli | sh` (Windows: `irm https://app.factory.ai/cli/windows | iex`), then run `droid` and sign in in your browser > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` @@ -82,7 +83,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- Provider setup: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) · [Droid](./docs/user/providers-droid.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..b175a17e4a21 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -39,6 +39,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "droid") { + return ( + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 377ae82aba8b..db029db2b581 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -22,7 +22,7 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { Approval needed - {props.approval.requestKind} + {props.approval.requestKind === "plan" ? "Plan approval" : props.approval.requestKind} {props.approval.detail ? ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..b7476d00c085 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,3 +1,4 @@ +import { resolveProviderDisplayName } from "@t3tools/client-runtime/providerDisplayName"; import type { ModelCapabilities, ModelSelection, @@ -33,9 +34,7 @@ function providerDisplayLabel(provider: { readonly instanceId: string; }): string { if (provider.displayName) return provider.displayName; - if (provider.driver === "codex") return "Codex"; - if (provider.driver === "claudeAgent") return "Claude"; - return provider.instanceId; + return resolveProviderDisplayName(provider.driver, provider.instanceId); } function normalizeSelectionOptions( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 55dcaa9fbad3..7ae3ab17666d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -15,6 +15,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -179,6 +180,60 @@ function makeThread( }; } +describe("derivePendingApprovals", () => { + it("maps plan approval requestType payloads into pending approvals", () => { + const activities = [ + makeActivity({ + id: EventId.make("approval-open-plan-approval"), + kind: "approval.requested", + summary: "Plan approval requested", + tone: "approval", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { + requestId: "req-plan-approval", + requestType: "plan_approval", + detail: "1. Map plan approvals\n2. Render the approval card", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-plan-approval", + requestKind: "plan", + createdAt: "2026-04-01T00:00:01.000Z", + detail: "1. Map plan approvals\n2. Render the approval card", + }, + ]); + }); + + it("maps dynamic tool calls into actionable generic approvals", () => { + const activities = [ + makeActivity({ + id: EventId.make("approval-open-dynamic-tool"), + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { + requestId: "req-dynamic-tool", + requestType: "dynamic_tool_call", + detail: "Search the web", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-dynamic-tool", + requestKind: "command", + createdAt: "2026-04-01T00:00:01.000Z", + detail: "Search the web", + }, + ]); + }); +}); + describe("buildThreadFeed", () => { it("keeps older local feedback before newer messages returned by the server", () => { const submission = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbde33da8514..1319a897903a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,3 +1,7 @@ +import { + approvalRequestKindFromPayload, + type ApprovalRequestKind, +} from "@t3tools/client-runtime/approvalRequests"; import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { OrchestrationLatestTurn, @@ -14,7 +18,7 @@ import * as Order from "effect/Order"; export interface PendingApproval { readonly requestId: ApprovalRequestId; - readonly requestKind: "command" | "file-read" | "file-change"; + readonly requestKind: ApprovalRequestKind; readonly createdAt: string; readonly detail?: string; } @@ -137,21 +141,6 @@ export type ThreadFeedLatestTurn = Pick< "turnId" | "state" | "startedAt" | "completedAt" >; -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - default: - return null; - } -} - function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { const normalized = detail?.toLowerCase(); if (!normalized) { @@ -964,14 +953,7 @@ function extractWorkLogItemType( function extractWorkLogRequestKind( payload: Record | null, ): WorkLogEntry["requestKind"] | undefined { - if ( - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ) { - return payload.requestKind; - } - return requestKindFromRequestType(payload?.requestType) ?? undefined; + return approvalRequestKindFromPayload(payload) ?? undefined; } function pushChangedFile(target: string[], seen: Set, value: unknown) { @@ -1375,12 +1357,7 @@ export function derivePendingApprovals( ? (activity.payload as Record) : null; const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); + const requestKind = approvalRequestKindFromPayload(payload); const detail = typeof payload?.detail === "string" ? payload.detail : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { diff --git a/apps/server/scripts/droid-mock-agent.ts b/apps/server/scripts/droid-mock-agent.ts new file mode 100644 index 000000000000..3004012d271e --- /dev/null +++ b/apps/server/scripts/droid-mock-agent.ts @@ -0,0 +1,801 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +const emitToolCall = process.env.T3_DROID_MOCK_EMIT_TOOL_CALL === "1"; +const requestPermission = process.env.T3_DROID_MOCK_REQUEST_PERMISSION === "1"; +const askUser = process.env.T3_DROID_MOCK_ASK_USER === "1"; +const hangTurn = process.env.T3_DROID_MOCK_HANG_TURN === "1"; +const failInit = process.env.T3_DROID_MOCK_FAIL_INIT === "1"; +const failUpdateSettings = process.env.T3_DROID_MOCK_FAIL_UPDATE_SETTINGS === "1"; +const loadInSpecMode = process.env.T3_DROID_MOCK_LOAD_IN_SPEC_MODE === "1"; +const loadSteeringMessages = process.env.T3_DROID_MOCK_LOAD_STEERING_MESSAGES === "1"; +const exitMidTurn = process.env.T3_DROID_MOCK_EXIT_MID_TURN === "1"; +const emitUnknownNotification = process.env.T3_DROID_MOCK_EMIT_UNKNOWN_NOTIFICATION === "1"; +const omitUsageNotification = process.env.T3_DROID_MOCK_OMIT_USAGE_NOTIFICATION === "1"; +const startRaceDir = process.env.T3_DROID_MOCK_START_RACE_DIR; + +const initializedSessionId = "mock-session-1"; +const knownLoadSessionId = "mock-session-known"; +const rewoundSessionId = "mock-session-rewound"; +const specSuccessorSessionId = "mock-session-spec-successor"; +const childSessionId = "mock-session-child"; +let currentSessionId = initializedSessionId; +let previousSessionId: string | undefined; +let emitPostLoadStraggler = false; +let serverRequestId = 0; +let currentSettings = { + modelId: "mock-fast", + reasoningEffort: "medium", + interactionMode: "auto", + autonomyLevel: "off", +}; +let activeTurn: + | { + readonly turnId: string; + completed: boolean; + } + | undefined; + +const pendingServerRequests = new Map< + string, + { + readonly resolve: (result: unknown) => void; + readonly reject: (error: Error) => void; + } +>(); + +const models = [ + { + id: "mock-fast", + displayName: "Mock Fast", + shortDisplayName: "Fast", + modelProvider: "factory", + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium", + isCustom: false, + }, + { + id: "mock-deep", + displayName: "Mock Deep", + shortDisplayName: "Deep", + modelProvider: "factory", + supportedReasoningEfforts: ["medium", "high", "xhigh"], + defaultReasoningEffort: "high", + isCustom: false, + }, +]; + +const tokenUsage = { + inputTokens: 20, + outputTokens: 8, + cacheCreationTokens: 1, + cacheReadTokens: 4, + thinkingTokens: 3, +}; + +function write(message: Record): void { + process.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + factoryApiVersion: "1.0.0", + ...message, + })}\n`, + ); +} + +function respond(id: string | number | null, result: unknown): void { + write({ type: "response", id, result }); +} + +function fail(id: string | number | null, code: number, message: string): void { + write({ type: "response", id, error: { code, message } }); +} + +function notifyForSession(sessionId: string, notification: Record): void { + write({ + type: "notification", + method: "droid.session_notification", + params: { sessionId, notification }, + }); +} + +function notify(notification: Record): void { + notifyForSession(currentSessionId, notification); +} + +function requestClient(method: string, params: unknown): Promise { + const id = `server-${++serverRequestId}`; + write({ type: "request", id, method, params }); + return new Promise((resolve, reject) => { + pendingServerRequests.set(id, { resolve, reject }); + }); +} + +function initializeResult() { + return { + sessionId: currentSessionId, + session: { messages: [] }, + availableModels: models, + settings: { + ...currentSettings, + availableAutonomyLevels: ["off", "low", "medium", "high"], + }, + }; +} + +function emitTerminalForSession(sessionId: string, reason: string, turnId: string): void { + notifyForSession(sessionId, { + type: "agent_turn_completed", + reason, + turnId, + tokenUsage, + cumulativeTokenUsage: tokenUsage, + durationMs: 10, + }); +} + +function emitTurnCompleted(reason: string, turnId: string): void { + if (!activeTurn || activeTurn.turnId !== turnId || activeTurn.completed) { + return; + } + activeTurn.completed = true; + if (!omitUsageNotification) { + notify({ + type: "session_token_usage_changed", + sessionId: currentSessionId, + tokenUsage, + inclusiveTokenUsage: tokenUsage, + lastCallTokenUsage: { + inputTokens: 7, + cacheReadTokens: 2, + outputTokens: 3, + }, + }); + } + emitTerminalForSession(currentSessionId, reason, turnId); + notify({ + type: "droid_working_state_changed", + newState: "idle", + }); + activeTurn = undefined; +} + +async function waitForFile(filePath: string): Promise { + await new Promise((resolve, reject) => { + let settled = false; + const watcher = NodeFS.watch(NodePath.dirname(filePath), (_eventType, filename) => { + if (String(filename) !== NodePath.basename(filePath)) return; + void NodeFSP.access(filePath).then(finish, () => {}); + }); + const finish = () => { + if (settled) return; + settled = true; + watcher.close(); + resolve(); + }; + watcher.once("error", (error) => { + if (settled) return; + settled = true; + reject(error); + }); + void NodeFSP.access(filePath).then(finish, () => {}); + }); +} + +async function runTurn(params: { + readonly messageId: string; + readonly text: string; +}): Promise { + const turnId = params.messageId; + activeTurn = { turnId, completed: false }; + + if (emitPostLoadStraggler && previousSessionId !== undefined) { + emitPostLoadStraggler = false; + notifyForSession(previousSessionId, { + type: "assistant_text_delta", + messageId: `assistant-stale-${turnId}`, + blockIndex: 0, + textDelta: "stale pre-rewind output", + }); + } + + notify({ type: "droid_working_state_changed", newState: "thinking" }); + notify({ + type: "thinking_text_delta", + messageId: `assistant-${turnId}`, + blockIndex: 0, + textDelta: "Mock thinking", + }); + + if (exitMidTurn) { + process.exit(7); + } + + if (emitUnknownNotification) { + notify({ + type: "future_mock_notification", + futurePayload: { supported: true }, + }); + } + + if (params.text === "mock spec handoff") { + notifyForSession(specSuccessorSessionId, { + type: "assistant_text_delta", + messageId: `assistant-successor-${turnId}`, + blockIndex: 0, + textDelta: "implementation successor", + }); + notifyForSession(specSuccessorSessionId, { + type: "assistant_text_complete", + messageId: `assistant-successor-${turnId}`, + blockIndex: 0, + }); + emitTerminalForSession(specSuccessorSessionId, "completed", `successor-${turnId}`); + emitTurnCompleted("spec_handoff", turnId); + return; + } + + if (params.text === "mock compaction") { + notify({ + type: "session_compacted", + summaryId: "mock-summary-1", + removedCount: 3, + visibleBoundaryMessageId: null, + }); + notify({ + type: "session_token_usage_changed", + sessionId: currentSessionId, + tokenUsage, + inclusiveTokenUsage: tokenUsage, + lastCallTokenUsage: { + inputTokens: 5, + cacheReadTokens: 1, + outputTokens: 2, + }, + }); + } + + if (params.text === "mock child session") { + notify({ + type: "child_session_available", + childSessionId, + description: "Mock delegated task", + timestamp: 1, + }); + notify({ + type: "tool_progress_update", + toolUseId: `child-task-${turnId}`, + toolName: "Task", + update: { + type: "message", + text: "Inspecting delegated files", + subagentSessionId: childSessionId, + }, + }); + notifyForSession(childSessionId, { + type: "assistant_text_delta", + messageId: `assistant-child-${turnId}`, + blockIndex: 0, + textDelta: "child-only output", + }); + emitTerminalForSession(childSessionId, "completed", `child-${turnId}`); + } + + if ( + params.text === "mock hanging child session" || + params.text === "mock child session then exit" + ) { + notify({ + type: "child_session_available", + childSessionId, + description: "Mock delegated task", + timestamp: 1, + }); + if (params.text === "mock child session then exit") { + await new Promise((resolve, reject) => + process.stdout.write("", (error) => (error ? reject(error) : resolve())), + ); + process.exit(7); + } + return; + } + + if (params.text === "mock taskless progress") { + notify({ + type: "tool_progress_update", + toolUseId: `parent-tool-${turnId}`, + toolName: "Execute", + update: { + type: "status", + status: "running", + }, + }); + } + + if (params.text === "mock report interaction mode") { + notify({ + type: "thinking_text_complete", + messageId: `assistant-${turnId}`, + blockIndex: 0, + durationMs: 5, + }); + notify({ + type: "assistant_text_delta", + messageId: `assistant-${turnId}`, + blockIndex: 1, + textDelta: currentSettings.interactionMode, + }); + notify({ + type: "assistant_text_complete", + messageId: `assistant-${turnId}`, + blockIndex: 1, + }); + emitTurnCompleted("completed", turnId); + return; + } + + if (params.text === "mock incomplete items") { + notify({ + type: "assistant_text_delta", + messageId: `assistant-${turnId}`, + blockIndex: 1, + textDelta: "terminal without item completions", + }); + notify({ + type: "tool_call", + toolUse: { + type: "tool_use", + id: `incomplete-tool-${turnId}`, + input: { command: "echo incomplete" }, + name: "Execute", + }, + }); + emitTurnCompleted("completed", turnId); + return; + } + + if (params.text === "mock delayed shared tool") { + notify({ + type: "tool_call", + toolUse: { + type: "tool_use", + id: "shared-tool-use", + input: { path: "README.md" }, + name: "Read", + }, + }); + return; + } + + if (params.text === "mock shared tool execute") { + notify({ + type: "tool_call", + toolUse: { + type: "tool_use", + id: "shared-tool-use", + input: { command: "echo shared" }, + name: "Execute", + }, + }); + notify({ + type: "tool_result", + messageId: `assistant-${turnId}`, + toolUseId: "shared-tool-use", + content: [{ type: "text", text: "shared command output" }], + }); + } + + if (params.text === "mock steering original") { + notify({ + type: "tool_call", + toolUse: { + type: "tool_use", + id: `steering-tool-${turnId}`, + input: { command: "echo steering" }, + name: "Execute", + }, + }); + return; + } + + if (requestPermission) { + const result = (await requestClient("droid.request_permission", { + toolUses: [ + { + toolUse: { + type: "tool_use", + id: `permission-tool-${turnId}`, + input: { command: "echo mock" }, + name: "Execute", + }, + confirmationType: "exec", + details: { + type: "exec", + fullCommand: "echo mock", + command: "echo", + impactLevel: "low", + riskLevelReason: "The mock command only prints text.", + }, + }, + ], + options: [ + { label: "Allow once", value: "proceed_once" }, + { label: "Deny", value: "cancel" }, + ], + })) as { selectedOption?: unknown }; + notify({ + type: "permission_resolved", + requestId: `permission-${turnId}`, + toolUseIds: [`permission-tool-${turnId}`], + selectedOption: typeof result.selectedOption === "string" ? result.selectedOption : "cancel", + }); + if (result.selectedOption === "cancel") { + emitTurnCompleted("permission_rejected", turnId); + return; + } + } + + if (askUser) { + await requestClient("droid.ask_user", { + toolCallId: `ask-${turnId}`, + questions: [ + { + index: 1, + topic: "Scope", + question: "Which scope?", + options: ["workspace", "session"], + }, + ], + }); + } + + if (emitToolCall) { + notify({ + type: "tool_call", + toolUse: { + type: "tool_use", + id: `tool-${turnId}`, + input: { path: "README.md" }, + name: "Read", + }, + }); + notify({ + type: "tool_result", + messageId: `assistant-${turnId}`, + toolUseId: `tool-${turnId}`, + content: [{ type: "text", text: "mock file contents" }], + }); + } + + notify({ + type: "thinking_text_complete", + messageId: `assistant-${turnId}`, + blockIndex: 0, + durationMs: 5, + }); + notify({ + type: "assistant_text_delta", + messageId: `assistant-${turnId}`, + blockIndex: 1, + textDelta: "hello from ", + }); + notify({ + type: "assistant_text_delta", + messageId: `assistant-${turnId}`, + blockIndex: 1, + textDelta: "droid mock", + }); + notify({ + type: "assistant_text_complete", + messageId: `assistant-${turnId}`, + blockIndex: 1, + }); + + if (hangTurn) { + return; + } + emitTurnCompleted("completed", turnId); +} + +async function handleRequest(message: { + readonly id: string | number | null; + readonly method: string; + readonly params?: unknown; +}): Promise { + switch (message.method) { + case "droid.initialize_session": + if (failInit) { + fail(message.id, -32603, "Mock initialization failure"); + } else { + if (startRaceDir) { + const firstInitPath = NodePath.join(startRaceDir, "first-init"); + try { + await NodeFSP.writeFile(firstInitPath, "", { flag: "wx" }); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") { + throw error; + } + await NodeFSP.writeFile(NodePath.join(startRaceDir, "replacement-init-started"), ""); + await waitForFile(NodePath.join(startRaceDir, "release-replacement-init")); + } + } + previousSessionId = undefined; + currentSessionId = initializedSessionId; + respond(message.id, initializeResult()); + } + return; + case "droid.load_session": + if ( + typeof message.params !== "object" || + message.params === null || + !("sessionId" in message.params) || + typeof message.params.sessionId !== "string" || + message.params.sessionId.length === 0 + ) { + fail(message.id, -32602, "load_session requires sessionId"); + return; + } + if ( + message.params.sessionId !== knownLoadSessionId && + message.params.sessionId !== rewoundSessionId + ) { + fail(message.id, -32004, "Mock session not found"); + return; + } + previousSessionId = currentSessionId; + currentSessionId = message.params.sessionId; + if (loadInSpecMode) { + currentSettings = { ...currentSettings, interactionMode: "spec" }; + } + if (currentSessionId === rewoundSessionId) { + emitPostLoadStraggler = true; + } + respond(message.id, { + session: { + title: "Loaded mock session", + messages: loadSteeringMessages + ? [ + { + id: "loaded-opening-user", + role: "user", + content: [{ type: "text", text: "loaded opening prompt" }], + }, + { + id: "loaded-steer-user", + role: "user", + content: [{ type: "text", text: "loaded steer" }], + }, + { + id: "loaded-assistant-1", + role: "assistant", + content: [{ type: "text", text: "loaded response" }], + }, + ] + : [ + { + id: "loaded-user-1", + role: "user", + content: [{ type: "text", text: "loaded prompt" }], + }, + { + id: "loaded-assistant-1", + role: "assistant", + content: [{ type: "text", text: "loaded response" }], + }, + ], + }, + settings: { + ...currentSettings, + ...(failUpdateSettings ? { autonomyLevel: "high" } : {}), + availableAutonomyLevels: ["off", "low", "medium", "high"], + }, + availableModels: models, + tokenUsage, + }); + return; + case "droid.add_user_message": { + const params = + typeof message.params === "object" && message.params !== null + ? (message.params as { messageId?: unknown; text?: unknown }) + : {}; + if ( + typeof params.messageId !== "string" || + params.messageId.length === 0 || + typeof params.text !== "string" + ) { + fail(message.id, -32602, "add_user_message requires messageId and text"); + return; + } + if (startRaceDir && params.text === "mock hold thread lock") { + await NodeFSP.writeFile(NodePath.join(startRaceDir, "thread-lock-held"), ""); + await waitForFile(NodePath.join(startRaceDir, "release-thread-lock")); + } + respond(message.id, {}); + if ( + params.text === "mock release shared tool" && + activeTurn !== undefined && + !activeTurn.completed + ) { + const openingTurnId = activeTurn.turnId; + notify({ + type: "create_message", + message: { + id: params.messageId, + role: "user", + content: [{ type: "text", text: params.text }], + }, + }); + notify({ + type: "tool_result", + messageId: `assistant-${openingTurnId}`, + toolUseId: "shared-tool-use", + content: [{ type: "text", text: "shared file contents" }], + }); + emitTurnCompleted("completed", openingTurnId); + return; + } + if ( + params.text === "mock steering coalesced" && + activeTurn !== undefined && + !activeTurn.completed + ) { + const openingTurnId = activeTurn.turnId; + notify({ + type: "create_message", + message: { + id: params.messageId, + role: "user", + content: [{ type: "text", text: params.text }], + }, + }); + notify({ + type: "assistant_text_delta", + messageId: `assistant-${openingTurnId}`, + blockIndex: 1, + textDelta: "steered output", + }); + notify({ + type: "assistant_text_complete", + messageId: `assistant-${openingTurnId}`, + blockIndex: 1, + }); + emitTurnCompleted("completed", openingTurnId); + return; + } + if ( + params.text === "mock steering separate" && + activeTurn !== undefined && + !activeTurn.completed + ) { + emitTurnCompleted("completed", activeTurn.turnId); + void runTurn({ messageId: params.messageId, text: params.text }); + return; + } + void runTurn({ messageId: params.messageId, text: params.text }); + return; + } + case "droid.interrupt_session": + if (activeTurn) { + emitTurnCompleted( + process.env.T3_DROID_MOCK_INTERRUPT_RACE === "1" ? "completed" : "cancelled", + activeTurn.turnId, + ); + } + respond(message.id, {}); + return; + case "droid.update_session_settings": + if (failUpdateSettings) { + fail(message.id, -32603, "Mock settings update failure"); + return; + } + if (typeof message.params === "object" && message.params !== null) { + currentSettings = { ...currentSettings, ...message.params }; + } + respond(message.id, {}); + notify({ + type: "settings_updated", + settings: currentSettings, + }); + return; + case "droid.list_models": + respond(message.id, { models }); + return; + case "droid.list_commands": + respond(message.id, { + commands: [ + { name: "review", description: "Review the current changes", argumentHint: "[path]" }, + ], + }); + return; + case "droid.list_skills": + respond(message.id, { + skills: [ + { + name: "mock-skill", + description: "A mock skill", + location: "personal", + filePath: "/mock/SKILL.md", + enabled: true, + }, + ], + projectAvailable: true, + }); + return; + case "droid.execute_rewind": + respond(message.id, { + newSessionId: rewoundSessionId, + restoredCount: 1, + deletedCount: 1, + failedRestoreCount: 0, + failedDeleteCount: 0, + }); + return; + default: + fail(message.id, -32601, `Unknown mock method: ${message.method}`); + } +} + +function handleMessage(raw: unknown): void { + if (typeof raw !== "object" || raw === null) { + return; + } + const message = raw as { + readonly type?: unknown; + readonly id?: unknown; + readonly method?: unknown; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: unknown; + }; + if ( + message.type === "response" && + (typeof message.id === "string" || typeof message.id === "number") + ) { + const pending = pendingServerRequests.get(String(message.id)); + if (!pending) { + return; + } + pendingServerRequests.delete(String(message.id)); + if (typeof message.error === "object" && message.error !== null) { + pending.reject(new Error(JSON.stringify(message.error))); + } else { + pending.resolve(message.result); + } + return; + } + if ( + message.type === "request" && + (typeof message.id === "string" || typeof message.id === "number" || message.id === null) && + typeof message.method === "string" + ) { + void handleRequest({ + id: message.id, + method: message.method, + params: message.params, + }); + } +} + +const input = NodeReadline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +input.on("line", (line) => { + if (line.trim().length === 0) { + return; + } + handleMessage(JSON.parse(line)); +}); + +input.once("close", () => { + process.exit(0); +}); + +process.once("SIGTERM", () => { + process.exit(0); +}); diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts new file mode 100644 index 000000000000..ea27c64d366a --- /dev/null +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -0,0 +1,188 @@ +import { DroidSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeDroidTextGeneration } from "../../textGeneration/DroidTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDroidAdapter } from "../Layers/DroidAdapter.ts"; +import { + buildInitialDroidProviderSnapshot, + checkDroidProviderStatus, + enrichDroidSnapshot, +} from "../Layers/DroidProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeDroidSettings = Schema.decodeSync(DroidSettings); + +const DRIVER_KIND = ProviderDriverKind.make("droid"); + +/** + * The curl installer puts droid in ~/.local/bin; the Windows PowerShell installer puts + * droid.exe in %USERPROFILE%\bin. Both are self-updating single-executable installs, so + * they update through `droid update`. Anything else (npm, bun, pnpm, Homebrew) keeps its + * package-manager update path. + */ +function isDroidNativeCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.endsWith("/.local/bin/droid") || /\/users\/[^/]+\/bin\/droid\.exe$/.test(normalized) + ); +} + +export const DroidProviderMaintenanceResolver = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@factory/cli", + homebrewFormula: null, + nativeUpdate: { + executable: "droid", + args: ["update"], + lockKey: "droid-native", + isCommandPath: isDroidNativeCommandPath, + }, +}); + +export type DroidDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const 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 fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DroidSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + DroidProviderMaintenanceResolver, + { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }, + ); + + const adapter = yield* makeDroidAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeDroidTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkDroidProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialDroidProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichDroidSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).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, + } satisfies ProviderInstance; + }), +}; 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 000000000000..4a1aa81af1a0 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -0,0 +1,1749 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + ApprovalRequestId, + DroidSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { droidTokenUsageSnapshot, makeDroidAdapter } from "./DroidAdapter.ts"; + +const decodeDroidSettings = Schema.decodeSync(DroidSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/droid-mock-agent.ts"); +const mockAgentCommand = process.execPath; +const mockAgentExec = `exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@"`; + +const droidAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-droid-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +type DroidTestAdapter = Effect.Success>; +type DroidAdapterOptions = NonNullable[1]>; +type DroidDebugStateReader = Parameters< + NonNullable +>[0]; +type DroidStartSessionInput = Parameters[0]; + +const makeDroidScenario = (mockEnv?: Record) => + Effect.gen(function* () { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "droid-jsonrpc-mock-")), + ); + const wrapperPath = NodePath.join(dir, "fake-droid.sh"); + const envExports = Object.entries(mockEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +${mockAgentExec} +`; + yield* Effect.promise(() => NodeFSP.writeFile(wrapperPath, script, "utf8")); + yield* Effect.promise(() => NodeFSP.chmod(wrapperPath, 0o755)); + + let debugStateReader: DroidDebugStateReader = () => + Effect.die("Droid debug state reader was not registered"); + const adapter = yield* makeDroidAdapter(decodeDroidSettings({ binaryPath: wrapperPath }), { + registerDebugStateReader: (read) => { + debugStateReader = read; + }, + }).pipe(Effect.orDie); + + return { + adapter, + readDebugState: (threadId: ThreadId) => debugStateReader(threadId), + }; + }); + +const startDroidSession = ( + adapter: DroidTestAdapter, + threadId: ThreadId, + runtimeMode: DroidStartSessionInput["runtimeMode"], +) => + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode, + }); + +const eventsForThread = (events: ReadonlyArray, threadId: ThreadId) => + events.filter((event) => String(event.threadId) === String(threadId)); + +async function waitForFile(filePath: string) { + await new Promise((resolve, reject) => { + let settled = false; + const watcher = NodeFS.watch(NodePath.dirname(filePath), (_eventType, filename) => { + if (String(filename) !== NodePath.basename(filePath)) return; + void NodeFSP.access(filePath).then(finish, () => {}); + }); + const finish = () => { + if (settled) return; + settled = true; + watcher.close(); + resolve(); + }; + watcher.once("error", (error) => { + if (settled) return; + settled = true; + reject(error); + }); + void NodeFSP.access(filePath).then(finish, () => {}); + }); +} + +it("counts cache creation as processed spend but not live context", () => { + const usage = { + inputTokens: 20, + outputTokens: 8, + cacheCreationTokens: 6, + cacheReadTokens: 4, + thinkingTokens: 3, + }; + assert.deepInclude(droidTokenUsageSnapshot(usage), { + usedTokens: 32, + totalProcessedTokens: 38, + }); + assert.deepInclude( + droidTokenUsageSnapshot(usage, { + inputTokens: 7, + cacheReadTokens: 2, + outputTokens: 3, + }), + { + usedTokens: 12, + totalProcessedTokens: 38, + lastUsedTokens: 12, + }, + ); +}); + +it.layer(droidAdapterTestLayer)("DroidAdapterLive", (it) => { + it.effect("maps a Droid turn to ordered reasoning, assistant, usage, and completion events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-turn-lifecycle"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("droid"), + model: "mock-deep", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + + assert.equal(session.provider, "droid"); + assert.equal(session.model, "mock-deep"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "hello droid", + attachments: [], + }); + const terminal = yield* Deferred.await(turnCompleted); + const threadEvents = eventsForThread(runtimeEvents, threadId); + const turnEvents = threadEvents.filter( + (event) => event.turnId !== undefined && String(event.turnId) === String(sentTurn.turnId), + ); + + assert.deepEqual( + turnEvents.map((event) => event.type), + [ + "turn.started", + "item.started", + "content.delta", + "item.completed", + "item.started", + "content.delta", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + const contentDeltas = turnEvents.filter( + (event): event is Extract => + event.type === "content.delta", + ); + assert.deepEqual( + contentDeltas.map((event) => [event.payload.streamKind, event.payload.delta]), + [ + ["reasoning_text", "Mock thinking"], + ["assistant_text", "hello from "], + ["assistant_text", "droid mock"], + ], + ); + assert.isTrue(contentDeltas.every((event) => event.raw === undefined)); + const startedItems = turnEvents.filter( + (event): event is Extract => + event.type === "item.started", + ); + assert.deepEqual( + startedItems.map((event) => event.payload.itemType), + ["reasoning", "assistant_message"], + ); + assert.equal(terminal.payload.state, "completed"); + assert.equal(terminal.payload.stopReason, "completed"); + + const usage = threadEvents.find( + (event): event is Extract => + event.type === "thread.token-usage.updated", + ); + assert.lengthOf( + threadEvents.filter((event) => event.type === "thread.token-usage.updated"), + 1, + ); + assert.deepEqual(usage?.payload.usage, { + usedTokens: 12, + totalProcessedTokens: 33, + inputTokens: 20, + cachedInputTokens: 4, + outputTokens: 8, + reasoningOutputTokens: 3, + lastUsedTokens: 12, + lastInputTokens: 7, + lastCachedInputTokens: 2, + lastOutputTokens: 3, + compactsAutomatically: true, + }); + assert.isTrue( + threadEvents.findIndex((event) => event === usage) < + threadEvents.findIndex((event) => event === terminal), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits terminal usage when no usage notification arrived", () => + Effect.gen(function* () { + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_OMIT_USAGE_NOTIFICATION: "1" }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const threadId = ThreadId.make("droid-usage-terminal-fallback"); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ threadId, input: "fallback usage", attachments: [] }); + yield* Deferred.await(turnCompleted); + + assert.lengthOf( + eventsForThread(runtimeEvents, threadId).filter( + (event) => event.type === "thread.token-usage.updated", + ), + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reaps thread locks after session teardown and failed startup", () => + Effect.gen(function* () { + const { adapter, readDebugState } = yield* makeDroidScenario(); + const { adapter: failingAdapter, readDebugState: readFailingDebugState } = + yield* makeDroidScenario({ T3_DROID_MOCK_FAIL_INIT: "1" }); + const threadId = ThreadId.make("droid-thread-lock-reaped"); + const failedThreadId = ThreadId.make("droid-failed-thread-lock-reaped"); + + yield* startDroidSession(adapter, threadId, "full-access"); + assert.equal((yield* readDebugState(threadId)).threadLockCount, 1); + yield* adapter.stopSession(threadId); + assert.equal((yield* readDebugState(threadId)).threadLockCount, 0); + + yield* Effect.flip(startDroidSession(failingAdapter, failedThreadId, "full-access")); + assert.equal((yield* readFailingDebugState(failedThreadId)).threadLockCount, 0); + }), + ); + + it.effect("waits for every same-thread start before stopAll returns", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-concurrent-start-stop-all"); + const coordinationDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "droid-start-race-")), + ); + const { adapter, readDebugState } = yield* makeDroidScenario({ + T3_DROID_MOCK_START_RACE_DIR: coordinationDir, + }); + + yield* startDroidSession(adapter, threadId, "full-access"); + + const heldTurn = yield* adapter + .sendTurn({ + threadId, + input: "mock hold thread lock", + attachments: [], + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => waitForFile(NodePath.join(coordinationDir, "thread-lock-held"))); + + const invalidStart = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("claude"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.flip, Effect.forkChild); + const replacementStart = yield* startDroidSession(adapter, threadId, "full-access").pipe( + Effect.forkChild, + ); + + yield* Effect.yieldNow; + assert.equal((yield* readDebugState(threadId)).threadLockReferenceCount, 3); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(coordinationDir, "release-thread-lock"), ""), + ); + const invalidStartError = yield* Fiber.join(invalidStart); + assert.equal(invalidStartError._tag, "ProviderAdapterValidationError"); + yield* Effect.promise(() => + waitForFile(NodePath.join(coordinationDir, "replacement-init-started")), + ); + + const stopAllCompleted = yield* Deferred.make(); + const stopAllFiber = yield* adapter.stopAll().pipe( + Effect.tap(() => Deferred.succeed(stopAllCompleted, undefined)), + Effect.forkChild, + ); + yield* Effect.yieldNow; + assert.isFalse( + yield* Deferred.isDone(stopAllCompleted), + "stopAll returned while a same-thread start was still initializing", + ); + + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(coordinationDir, "release-replacement-init"), ""), + ); + yield* Fiber.join(replacementStart); + yield* Fiber.join(stopAllFiber); + yield* Fiber.join(heldTurn); + + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + + it.effect("closes incomplete streamed and tool items before terminal settlement", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-incomplete-items"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "mock incomplete items", + attachments: [], + }); + const terminal = yield* Deferred.await(turnCompleted); + const turnEvents = eventsForThread(runtimeEvents, threadId).filter( + (event) => event.turnId !== undefined && String(event.turnId) === String(sentTurn.turnId), + ); + const terminalIndex = turnEvents.findIndex((event) => event === terminal); + const started = turnEvents.filter( + (event): event is Extract => + event.type === "item.started", + ); + const completed = turnEvents.filter( + (event): event is Extract => + event.type === "item.completed", + ); + + assert.deepEqual( + started.map((event) => [String(event.itemId), event.payload.itemType]), + [ + [`reasoning:assistant-${String(sentTurn.turnId)}`, "reasoning"], + [`msg:assistant-${String(sentTurn.turnId)}`, "assistant_message"], + [`incomplete-tool-${String(sentTurn.turnId)}`, "command_execution"], + ], + ); + assert.deepEqual( + completed.map((event) => [String(event.itemId), event.payload.itemType]), + started.map((event) => [String(event.itemId), event.payload.itemType]), + ); + assert.isTrue( + completed.every( + (event) => turnEvents.findIndex((candidate) => candidate === event) < terminalIndex, + ), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps tool-use names isolated between concurrent Droid sessions", () => + Effect.gen(function* () { + const firstThreadId = ThreadId.make("droid-shared-tool-first"); + const secondThreadId = ThreadId.make("droid-shared-tool-second"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstToolStarted = + yield* Deferred.make>(); + const firstTurnCompleted = + yield* Deferred.make>(); + const secondTurnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "item.started" && + String(event.threadId) === String(firstThreadId) && + String(event.itemId) === "shared-tool-use" + ? Deferred.succeed(firstToolStarted, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(firstThreadId) + ? Deferred.succeed(firstTurnCompleted, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && + String(event.threadId) === String(secondThreadId) + ? Deferred.succeed(secondTurnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, firstThreadId, "full-access"); + yield* startDroidSession(adapter, secondThreadId, "full-access"); + yield* adapter.sendTurn({ + threadId: firstThreadId, + input: "mock delayed shared tool", + attachments: [], + }); + yield* Deferred.await(firstToolStarted); + + yield* adapter.sendTurn({ + threadId: secondThreadId, + input: "mock shared tool execute", + attachments: [], + }); + yield* Deferred.await(secondTurnCompleted); + + yield* adapter.sendTurn({ + threadId: firstThreadId, + input: "mock release shared tool", + attachments: [], + }); + yield* Deferred.await(firstTurnCompleted); + + const firstToolCompleted = eventsForThread(runtimeEvents, firstThreadId).find( + (event): event is Extract => + event.type === "item.completed" && String(event.itemId) === "shared-tool-use", + ); + assert.equal(firstToolCompleted?.payload.itemType, "dynamic_tool_call"); + assert.equal(firstToolCompleted?.payload.title, "Read"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(firstThreadId); + yield* adapter.stopSession(secondThreadId); + }), + ); + + it.effect("round-trips an approved Droid permission and completes the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-permission-approved"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_REQUEST_PERMISSION: "1" }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const requestOpened = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "request.opened" && String(event.threadId) === String(threadId) + ? Deferred.succeed(requestOpened, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "approval-required"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "run the approved command", + attachments: [], + }); + + const opened = yield* Deferred.await(requestOpened); + assert.equal(String(opened.turnId), String(sentTurn.turnId)); + assert.equal(opened.payload.requestType, "exec_command_approval"); + assert.equal(opened.payload.detail, "echo mock"); + assert.deepInclude(opened.payload.args, { + toolUses: [ + { + toolUse: { + type: "tool_use", + id: `permission-tool-${String(sentTurn.turnId)}`, + input: { command: "echo mock" }, + name: "Execute", + }, + confirmationType: "exec", + details: { + type: "exec", + fullCommand: "echo mock", + command: "echo", + impactLevel: "low", + riskLevelReason: "The mock command only prints text.", + }, + }, + ], + options: [ + { label: "Allow once", value: "proceed_once" }, + { label: "Deny", value: "cancel" }, + ], + }); + assert.equal(opened.raw?.method, "droid.request_permission"); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(opened.requestId)), + "accept", + ); + const terminal = yield* Deferred.await(turnCompleted); + const resolved = eventsForThread(runtimeEvents, threadId).find( + (event): event is Extract => + event.type === "request.resolved" && String(event.requestId) === String(opened.requestId), + ); + + assert.equal(resolved?.payload.requestType, "exec_command_approval"); + assert.equal(resolved?.payload.decision, "accept"); + assert.equal(terminal.payload.state, "completed"); + assert.equal(terminal.payload.stopReason, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects a concurrent duplicate Droid permission response", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-permission-response-race"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_REQUEST_PERMISSION: "1" }); + const requestOpened = + yield* Deferred.make>(); + const requestResolved = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) return Effect.void; + if (event.type === "request.opened") { + return Deferred.succeed(requestOpened, event).pipe(Effect.ignore); + } + if (event.type === "request.resolved") { + return Deferred.succeed(requestResolved, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "approval-required"); + yield* adapter.sendTurn({ + threadId, + input: "race permission responses", + attachments: [], + }); + + const opened = yield* Deferred.await(requestOpened); + const requestId = ApprovalRequestId.make(String(opened.requestId)); + const outcomes = yield* Effect.all( + [ + { label: "accept", decision: "accept" as const }, + { label: "decline", decision: "decline" as const }, + ].map(({ label, decision }) => + adapter.respondToRequest(threadId, requestId, decision).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Failure" as const, label, error }), + onSuccess: () => ({ _tag: "Success" as const, label, decision }), + }), + ), + ), + { concurrency: "unbounded" }, + ); + const successes = outcomes.filter((outcome) => outcome._tag === "Success"); + const failures = outcomes.filter((outcome) => outcome._tag === "Failure"); + + assert.lengthOf(successes, 1, "exactly one concurrent approval response should succeed"); + assert.lengthOf(failures, 1, "the duplicate approval response should fail"); + const duplicateFailure = failures[0]; + assert.equal(duplicateFailure?.error._tag, "ProviderAdapterRequestError"); + if (duplicateFailure?.error._tag === "ProviderAdapterRequestError") { + assert.include(duplicateFailure.error.detail, "Unknown pending approval request"); + } + + const resolved = yield* Deferred.await(requestResolved); + const appliedDecision = successes[0]?.decision; + assert.isDefined(appliedDecision); + assert.equal(resolved.payload.decision, appliedDecision); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("round-trips a denied Droid permission as a cancelled turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-permission-denied"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_REQUEST_PERMISSION: "1" }); + const requestOpened = + yield* Deferred.make>(); + const requestResolved = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) return Effect.void; + if (event.type === "request.opened") { + return Deferred.succeed(requestOpened, event).pipe(Effect.ignore); + } + if (event.type === "request.resolved") { + return Deferred.succeed(requestResolved, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "approval-required"); + yield* adapter.sendTurn({ + threadId, + input: "deny the command", + attachments: [], + }); + + const opened = yield* Deferred.await(requestOpened); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(opened.requestId)), + "decline", + ); + const resolved = yield* Deferred.await(requestResolved); + const terminal = yield* Deferred.await(turnCompleted); + + assert.equal(String(resolved.requestId), String(opened.requestId)); + assert.equal(resolved.payload.decision, "decline"); + assert.equal(terminal.payload.state, "cancelled"); + assert.equal(terminal.payload.stopReason, "permission_rejected"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("round-trips Droid ask_user answers and completes the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-ask-user"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_ASK_USER: "1" }); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) return Effect.void; + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "ask for scope", + attachments: [], + }); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(String(requestedEvent.turnId), String(sentTurn.turnId)); + assert.deepEqual(requestedEvent.payload.questions, [ + { + id: "1", + header: "Scope", + question: "Which scope?", + options: [ + { label: "workspace", description: "workspace" }, + { label: "session", description: "session" }, + ], + multiSelect: false, + }, + ]); + assert.equal(requestedEvent.raw?.method, "droid.ask_user"); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { "1": "workspace" }, + ); + const resolvedEvent = yield* Deferred.await(resolved); + const terminal = yield* Deferred.await(turnCompleted); + + assert.equal(String(resolvedEvent.requestId), String(requestedEvent.requestId)); + assert.deepEqual(resolvedEvent.payload.answers, { "1": "workspace" }); + assert.equal(terminal.payload.state, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects a concurrent duplicate Droid user-input response", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-user-input-response-race"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_ASK_USER: "1" }); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) return Effect.void; + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "race user input responses", + attachments: [], + }); + + const requestedEvent = yield* Deferred.await(requested); + const requestId = ApprovalRequestId.make(String(requestedEvent.requestId)); + const outcomes = yield* Effect.all( + [ + { label: "workspace", answers: { "1": "workspace" } }, + { label: "session", answers: { "1": "session" } }, + ].map(({ label, answers }) => + adapter.respondToUserInput(threadId, requestId, answers).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Failure" as const, label, error }), + onSuccess: () => ({ _tag: "Success" as const, label, answers }), + }), + ), + ), + { concurrency: "unbounded" }, + ); + const successes = outcomes.filter((outcome) => outcome._tag === "Success"); + const failures = outcomes.filter((outcome) => outcome._tag === "Failure"); + + assert.lengthOf(successes, 1, "exactly one concurrent user-input response should succeed"); + assert.lengthOf(failures, 1, "the duplicate user-input response should fail"); + const duplicateFailure = failures[0]; + assert.equal(duplicateFailure?.error._tag, "ProviderAdapterRequestError"); + if (duplicateFailure?.error._tag === "ProviderAdapterRequestError") { + assert.include(duplicateFailure.error.detail, "Unknown pending user-input request"); + } + + const resolvedEvent = yield* Deferred.await(resolved); + const appliedAnswers = successes[0]?.answers; + assert.isDefined(appliedAnswers); + assert.deepEqual(resolvedEvent.payload.answers, appliedAnswers); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("interrupts a hanging Droid turn once and drops its late terminal notification", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-interrupt"); + const { adapter, readDebugState } = yield* makeDroidScenario({ + T3_DROID_MOCK_HANG_TURN: "1", + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const assistantCompleted = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "item.completed" && + event.payload.itemType === "assistant_message" && + String(event.threadId) === String(threadId) + ? Deferred.succeed(assistantCompleted, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "hang until interrupted", + attachments: [], + }); + yield* Deferred.await(assistantCompleted); + yield* adapter.interruptTurn(threadId, sentTurn.turnId); + const terminal = yield* Deferred.await(turnCompleted); + const threadEvents = eventsForThread(runtimeEvents, threadId); + + assert.equal(String(terminal.turnId), String(sentTurn.turnId)); + assert.equal(terminal.payload.state, "cancelled"); + assert.equal(terminal.payload.stopReason, "cancelled"); + assert.equal((yield* readDebugState(threadId)).interruptedTurnCount, 0); + assert.lengthOf( + threadEvents.filter( + (event) => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId), + ), + 1, + ); + + const terminalIndex = threadEvents.findIndex((event) => event === terminal); + const turnOutputTypes = new Set(["content.delta", "item.started", "item.completed"]); + assert.deepEqual( + threadEvents + .slice(terminalIndex + 1) + .filter( + (event) => + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId) && + turnOutputTypes.has(event.type), + ), + [], + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("loads a known Droid resume cursor into a ready session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-resume-known"); + const { adapter } = yield* makeDroidScenario(); + const sessionStarted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.started" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sessionStarted, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-known" }, + }); + const started = yield* Deferred.await(sessionStarted); + + assert.equal(session.status, "ready"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-known", + }); + assert.equal(started.payload.resume, true); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resets a resumed spec session before sending a normal turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-resume-spec-reset"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_LOAD_IN_SPEC_MODE: "1" }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-known" }, + }); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "mock report interaction mode", + attachments: [], + }); + yield* Deferred.await(turnCompleted); + + const assistantText = eventsForThread(runtimeEvents, threadId) + .filter( + (event): event is Extract => + event.type === "content.delta" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId) && + event.payload.streamKind === "assistant_text", + ) + .map((event) => event.payload.delta) + .join(""); + assert.equal(assistantText, "auto"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects an unknown Droid resume cursor with a typed process error", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-resume-unknown"); + const { adapter } = yield* makeDroidScenario(); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-missing" }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "Mock session not found"); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + + it.effect("fails resume when approval-required settings cannot be reasserted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-resume-settings-failure"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_FAIL_UPDATE_SETTINGS: "1" }); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-known" }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "Mock settings update failure"); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + + it.effect("surfaces Droid initialization failure as a typed process error", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-init-failure"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_FAIL_INIT: "1" }); + + const error = yield* Effect.flip(startDroidSession(adapter, threadId, "full-access")); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "Mock initialization failure"); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + + it.effect("fails the active turn and emits session.exited when Droid dies", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-process-death"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_EXIT_MID_TURN: "1" }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sessionExited = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "session.exited" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sessionExited, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "exit during this turn", + attachments: [], + }); + const exited = yield* Deferred.await(sessionExited); + const failedTurn = eventsForThread(runtimeEvents, threadId).find( + (event): event is Extract => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId), + ); + + assert.equal(failedTurn?.payload.state, "failed"); + assert.include(failedTurn?.payload.errorMessage ?? "", "Droid exited unexpectedly"); + assert.equal(exited.payload.exitKind, "error"); + assert.isFalse(yield* adapter.hasSession(threadId)); + + yield* Fiber.interrupt(runtimeEventsFiber); + }), + ); + + it.effect("rolls back a turn by forking the Droid session and re-anchoring on the fork", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-rollback"); + const { adapter } = yield* makeDroidScenario(); + const firstTurnCompleted = + yield* Deferred.make>(); + const secondTurnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(firstTurnCompleted, event).pipe( + Effect.flatMap((wasFirst) => + wasFirst ? Effect.void : Deferred.succeed(secondTurnCompleted, event), + ), + Effect.asVoid, + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "first turn to roll back", + attachments: [], + }); + yield* Deferred.await(firstTurnCompleted); + + const snapshot = yield* adapter.rollbackThread(threadId, 1); + assert.deepEqual(snapshot.turns, []); + + // The live process re-anchored on the fork: the resume cursor points at + // the rewound session and the session still takes turns. + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "turn after the rewind", + attachments: [], + }); + assert.deepStrictEqual(nextTurn.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-rewound", + }); + yield* Deferred.await(secondTurnCompleted); + + // Rolling back past the turns tracked in this process is refused + // rather than mis-anchored (rollback of the post-rewind turn is fine, + // two turns is not). + const tooDeep = yield* Effect.flip(adapter.rollbackThread(threadId, 2)); + assert.equal(tooDeep._tag, "ProviderAdapterRequestError"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("drops a pre-rewind session straggler after re-anchoring on the fork", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-rewind-straggler"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnCompleted = + yield* Deferred.make>(); + const secondTurnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(firstTurnCompleted, event).pipe( + Effect.flatMap((wasFirst) => + wasFirst ? Effect.void : Deferred.succeed(secondTurnCompleted, event), + ), + Effect.asVoid, + ) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "turn before straggler rewind", + attachments: [], + }); + yield* Deferred.await(firstTurnCompleted); + yield* adapter.rollbackThread(threadId, 1); + + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "turn after straggler rewind", + attachments: [], + }); + const terminal = yield* Deferred.await(secondTurnCompleted); + const nextTurnEvents = eventsForThread(runtimeEvents, threadId).filter( + (event) => event.turnId !== undefined && String(event.turnId) === String(nextTurn.turnId), + ); + + assert.equal(terminal.payload.state, "completed"); + assert.notInclude( + nextTurnEvents + .filter( + (event): event is Extract => + event.type === "content.delta", + ) + .map((event) => event.payload.delta) + .join(""), + "stale pre-rewind output", + ); + assert.lengthOf( + nextTurnEvents.filter((event) => event.type === "turn.completed"), + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("lets interrupt cancellation win a queued completed-terminal race", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-interrupt-completion-race"); + const { adapter } = yield* makeDroidScenario({ + T3_DROID_MOCK_HANG_TURN: "1", + T3_DROID_MOCK_INTERRUPT_RACE: "1", + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const assistantCompleted = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "item.completed" && + event.payload.itemType === "assistant_message" && + String(event.threadId) === String(threadId) + ? Deferred.succeed(assistantCompleted, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "race completion against interrupt", + attachments: [], + }); + yield* Deferred.await(assistantCompleted); + yield* adapter.interruptTurn(threadId, sentTurn.turnId); + const terminal = yield* Deferred.await(turnCompleted); + + assert.equal(terminal.payload.state, "cancelled"); + assert.lengthOf( + eventsForThread(runtimeEvents, threadId).filter( + (event) => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId), + ), + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("adopts a spec-handoff successor after streaming it into the plan turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-spec-handoff"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "mock spec handoff", + attachments: [], + interactionMode: "plan", + }); + const terminal = yield* Deferred.await(turnCompleted); + const sessions = yield* adapter.listSessions(); + const successorText = eventsForThread(runtimeEvents, threadId) + .filter( + (event): event is Extract => + event.type === "content.delta" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId) && + event.payload.streamKind === "assistant_text", + ) + .map((event) => event.payload.delta) + .join(""); + + assert.include(successorText, "implementation successor"); + assert.equal(terminal.payload.state, "completed"); + assert.equal(terminal.payload.stopReason, "spec_handoff"); + assert.deepStrictEqual(sessions[0]?.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-spec-successor", + }); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("treats compaction as a no-op and reports the last-call context meter", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-compaction"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "mock compaction", + attachments: [], + }); + const terminal = yield* Deferred.await(turnCompleted); + const threadEvents = eventsForThread(runtimeEvents, threadId); + const compactedUsage = threadEvents + .filter( + (event): event is Extract => + event.type === "thread.token-usage.updated", + ) + .find((event) => event.payload.usage.lastUsedTokens === 8); + + assert.equal(terminal.payload.state, "completed"); + assert.lengthOf( + threadEvents.filter( + (event) => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(sentTurn.turnId), + ), + 1, + ); + assert.deepInclude(compactedUsage?.payload.usage, { + usedTokens: 8, + totalProcessedTokens: 33, + lastUsedTokens: 8, + lastInputTokens: 5, + lastCachedInputTokens: 1, + lastOutputTokens: 2, + compactsAutomatically: true, + }); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("maps child sessions to tasks without leaking child deltas into the main turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-child-session"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const sentTurn = yield* adapter.sendTurn({ + threadId, + input: "mock child session", + attachments: [], + }); + yield* Deferred.await(turnCompleted); + const threadEvents = eventsForThread(runtimeEvents, threadId); + + assert.lengthOf( + threadEvents.filter((event) => event.type === "task.started"), + 1, + ); + assert.lengthOf( + threadEvents.filter((event) => event.type === "task.completed"), + 1, + ); + const progress = threadEvents.filter( + (event): event is Extract => + event.type === "tool.progress", + ); + assert.lengthOf(progress, 1); + assert.equal(String(progress[0]?.payload.taskId), "mock-session-child"); + assert.equal(progress[0]?.payload.toolUseId, `child-task-${String(sentTurn.turnId)}`); + assert.equal(progress[0]?.payload.toolName, "Task"); + assert.equal(progress[0]?.payload.summary, "Inspecting delegated files"); + assert.notInclude( + threadEvents + .filter( + (event): event is Extract => + event.type === "content.delta", + ) + .map((event) => event.payload.delta) + .join(""), + "child-only output", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stops open Droid child tasks before an explicit session exit", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-child-session-stop"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const taskStarted = + yield* Deferred.make>(); + const sessionExited = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "task.started" && String(event.threadId) === String(threadId) + ? Deferred.succeed(taskStarted, event).pipe(Effect.asVoid) + : event.type === "session.exited" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sessionExited, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "mock hanging child session", + attachments: [], + }); + const started = yield* Deferred.await(taskStarted); + yield* adapter.stopSession(threadId); + const exited = yield* Deferred.await(sessionExited); + const threadEvents = eventsForThread(runtimeEvents, threadId); + const taskCompleted = threadEvents.filter( + (event): event is Extract => + event.type === "task.completed" && + String(event.payload.taskId) === String(started.payload.taskId), + ); + + assert.lengthOf(taskCompleted, 1, "stopping the session should settle its open child task"); + assert.equal(taskCompleted[0]?.payload.status, "stopped"); + assert.isBelow(threadEvents.indexOf(taskCompleted[0]!), threadEvents.indexOf(exited)); + + yield* Fiber.interrupt(runtimeEventsFiber); + }), + ); + + it.effect("stops open Droid child tasks before an unexpected process exit", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-child-session-process-exit"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const taskStarted = + yield* Deferred.make>(); + const sessionExited = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "task.started" && String(event.threadId) === String(threadId) + ? Deferred.succeed(taskStarted, event).pipe(Effect.asVoid) + : event.type === "session.exited" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sessionExited, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "mock child session then exit", + attachments: [], + }); + const started = yield* Deferred.await(taskStarted); + const exited = yield* Deferred.await(sessionExited); + const threadEvents = eventsForThread(runtimeEvents, threadId); + const taskCompleted = threadEvents.filter( + (event): event is Extract => + event.type === "task.completed" && + String(event.payload.taskId) === String(started.payload.taskId), + ); + + assert.lengthOf( + taskCompleted, + 1, + "unexpected process exit should settle its open child task", + ); + assert.equal(taskCompleted[0]?.payload.status, "stopped"); + assert.isBelow(threadEvents.indexOf(taskCompleted[0]!), threadEvents.indexOf(exited)); + + yield* Fiber.interrupt(runtimeEventsFiber); + }), + ); + + it.effect("drops Droid tool progress without an owning subagent session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-taskless-tool-progress"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "mock taskless progress", + attachments: [], + }); + yield* Deferred.await(turnCompleted); + + assert.lengthOf( + eventsForThread(runtimeEvents, threadId).filter((event) => event.type === "tool.progress"), + 0, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refuses to guess rollback anchors from resumed steering messages", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-resumed-rollback"); + const { adapter } = yield* makeDroidScenario({ T3_DROID_MOCK_LOAD_STEERING_MESSAGES: "1" }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-known" }, + }); + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.include(error.detail, "only 0 tracked in this session"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles a coalesced steering turn exactly once", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-steering-coalesced"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const steeringReady = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "item.started" && + event.payload.itemType === "command_execution" && + String(event.threadId) === String(threadId) + ? Deferred.succeed(steeringReady, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const openingTurn = yield* adapter.sendTurn({ + threadId, + input: "mock steering original", + attachments: [], + }); + yield* Deferred.await(steeringReady); + const steeredTurn = yield* adapter.sendTurn({ + threadId, + input: "mock steering coalesced", + attachments: [], + }); + const terminal = yield* Deferred.await(turnCompleted); + + assert.equal(String(steeredTurn.turnId), String(openingTurn.turnId)); + assert.equal(terminal.payload.state, "completed"); + assert.lengthOf( + eventsForThread(runtimeEvents, threadId).filter( + (event) => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(openingTurn.turnId), + ), + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a steered turn open when the queued message runs separately", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-steering-separate"); + const { adapter } = yield* makeDroidScenario(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const steeringReady = + yield* Deferred.make>(); + const separateAssistantCompleted = + yield* Deferred.make>(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "item.started" && + event.payload.itemType === "command_execution" && + String(event.threadId) === String(threadId) + ? Deferred.succeed(steeringReady, event).pipe(Effect.asVoid) + : event.type === "item.completed" && + event.payload.itemType === "assistant_message" && + String(event.threadId) === String(threadId) + ? Deferred.succeed(separateAssistantCompleted, event).pipe(Effect.asVoid) + : event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + const openingTurn = yield* adapter.sendTurn({ + threadId, + input: "mock steering original", + attachments: [], + }); + yield* Deferred.await(steeringReady); + const steeredTurn = yield* adapter.sendTurn({ + threadId, + input: "mock steering separate", + attachments: [], + }); + yield* Deferred.await(separateAssistantCompleted); + const terminal = yield* Deferred.await(turnCompleted); + + assert.equal(String(steeredTurn.turnId), String(openingTurn.turnId)); + assert.equal(terminal.payload.state, "completed"); + assert.lengthOf( + eventsForThread(runtimeEvents, threadId).filter( + (event) => + event.type === "turn.completed" && + event.turnId !== undefined && + String(event.turnId) === String(openingTurn.turnId), + ), + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores unknown Droid notifications and still completes the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("droid-unknown-notification"); + const { adapter } = yield* makeDroidScenario({ + T3_DROID_MOCK_EMIT_UNKNOWN_NOTIFICATION: "1", + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startDroidSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ + threadId, + input: "tolerate future notifications", + attachments: [], + }); + const terminal = yield* Deferred.await(turnCompleted); + const assistantText = eventsForThread(runtimeEvents, threadId) + .filter( + (event): event is Extract => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ) + .map((event) => event.payload.delta) + .join(""); + + assert.equal(assistantText, "hello from droid mock"); + assert.equal(terminal.payload.state, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts new file mode 100644 index 000000000000..8e1cc6c51067 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -0,0 +1,1947 @@ +import { + ApprovalRequestId, + type CanonicalRequestType, + type DroidSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + type ThreadTokenUsageSnapshot, + type ThreadId, + type ToolLifecycleItemType, + TurnId, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { + DroidAskUserRequest, + DroidExecuteRewindResult, + DroidInitializeSessionResult, + DroidLoadSessionResult, + DroidPermissionRequest, + type DroidLastCallTokenUsage, + type DroidPermissionOption, + type DroidSessionNotification, + type DroidTokenUsage, + type DroidToolUse, +} from "../droid/DroidProtocol.ts"; +import { + makeDroidRpcClient, + type DroidRpcClient, + type DroidServerRequest, +} from "../droid/DroidRpcClient.ts"; +import { type DroidAdapterShape } from "../Services/DroidAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("droid"); +const DROID_RESUME_VERSION = 1 as const; +const SESSION_INIT_TIMEOUT_MS = 75_000; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface DroidAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + /** Test-only visibility into adapter-owned collections. */ + readonly registerDebugStateReader?: ( + read: (threadId: ThreadId) => Effect.Effect<{ + readonly threadLockCount: number; + readonly threadLockReferenceCount: number; + readonly interruptedTurnCount: number; + }>, + ) => void; +} + +interface ThreadLockEntry { + readonly semaphore: Semaphore.Semaphore; + readonly references: number; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface DroidSessionContext { + readonly threadId: ThreadId; + /** Mutable: rewind/compact mint a successor session id. */ + droidSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly rpc: DroidRpcClient; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late completions must not resurrect them. */ + readonly interruptedTurnIds: Set; + /** + * Message ids accepted into the current logical t3 turn. A sendTurn while + * this is non-empty is a steer: droid may either coalesce it into the active + * physical run or execute it as a later physical run. + */ + readonly pendingTurnMessageIds: Set; + /** + * Pending message ids whose live create_message notification has arrived. + * At an earlier physical turn's terminal, these are the steers known to have + * been coalesced into that run. + */ + readonly persistedPendingTurnMessageIds: Set; + /** Runtime item ids with an emitted item.started awaiting completion. */ + readonly openItemIds: Set; + /** Tool names keyed by provider tool-use id within this Droid session. */ + readonly toolUseNames: Map; + /** Droid child (subagent) session ids mapped onto t3 task lifecycles. */ + readonly childSessions: Map; + /** + * Implementation session minted by a spec handoff. It streams into the same + * t3 turn before the spec session's terminal notification arrives, and is + * adopted as the live session id when that terminal settles the turn. + */ + specSuccessorSessionId: string | undefined; + /** Live-context meter from the most recent session_token_usage_changed. */ + lastCallTokenUsage: DroidLastCallTokenUsage | undefined; + lastEmittedTokenUsage: ThreadTokenUsageSnapshot | undefined; + currentModelId: string | undefined; + currentReasoningEffort: string | undefined; + currentInteractionMode: "auto" | "spec"; + stopped: boolean; +} + +/** t3 runtime modes map 1:1 onto droid autonomy levels. */ +export function droidAutonomyLevelForRuntimeMode( + runtimeMode: ProviderSession["runtimeMode"], +): "off" | "low" | "medium" | "high" { + switch (runtimeMode) { + case "approval-required": + return "off"; + case "auto-accept-edits": + return "low"; + case "auto": + return "medium"; + case "full-access": + return "high"; + } +} + +export function droidToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + if (toolName.startsWith("mcp__") || toolName.startsWith("mcp_")) return "mcp_tool_call"; + switch (toolName) { + case "Execute": + case "Bash": + return "command_execution"; + case "Edit": + case "Create": + case "Write": + case "ApplyPatch": + return "file_change"; + case "WebSearch": + case "FetchUrl": + return "web_search"; + case "Task": + return "collab_agent_tool_call"; + default: + return "dynamic_tool_call"; + } +} + +/** + * Every real droid confirmation type maps onto a canonical request type the + * clients render; nothing may land on "unknown", which clients drop, leaving + * an unanswerable hang. + */ +export function droidCanonicalRequestType( + confirmationType: string | undefined, +): CanonicalRequestType { + switch (confirmationType) { + case "exec": + return "exec_command_approval"; + case "edit": + case "create": + return "file_change_approval"; + case "apply_patch": + return "apply_patch_approval"; + case "mcp_tool": + case "ask_user": + case "start_mission_run": + return "dynamic_tool_call"; + case "exit_spec_mode": + case "propose_mission": + return "plan_approval"; + case "sandbox_violation": + case "droid_shield_violation": + return "command_execution_approval"; + default: + return "unknown"; + } +} + +/** + * Pick the droid confirmation outcome for a t3 approval decision. The reply + * must be one of the outcomes the request offered; anything else is treated + * as cancel by the CLI, so unmatched preferences fall back explicitly. + */ +export function selectDroidPermissionOutcome( + options: ReadonlyArray, + decision: Exclude, +): string | undefined { + const outcomes = options + .map((option) => option.outcome.trim()) + .filter((outcome): outcome is string => Boolean(outcome)); + const preference = + decision === "acceptForSession" + ? ["proceed_always", "proceed_always_file", "proceed_always_tools", "proceed_always_server"] + : decision === "accept" + ? ["proceed_once"] + : ["cancel"]; + for (const preferred of preference) { + if (outcomes.includes(preferred)) return preferred; + } + if (decision === "decline") return outcomes.includes("cancel") ? "cancel" : undefined; + // Approvals with bespoke outcome sets (spec exit, autonomy raises) still + // proceed on the first non-cancel option droid offered. + return outcomes.find((outcome) => outcome !== "cancel"); +} + +/** + * Cumulative session spend is not the context meter: droid compacts + * automatically and reports the live context in `lastCallTokenUsage`. Use the + * last call for `usedTokens` when droid sent one, and keep the cumulative + * (child-inclusive) spend as `totalProcessedTokens`. + */ +export function droidTokenUsageSnapshot( + usage: DroidTokenUsage, + lastCall?: DroidLastCallTokenUsage, +): ThreadTokenUsageSnapshot { + const inputTokens = usage.inputTokens ?? 0; + const cachedInputTokens = usage.cacheReadTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + const usedTokens = inputTokens + cachedInputTokens + outputTokens; + const totalProcessedTokens = usedTokens + (usage.cacheCreationTokens ?? 0); + const lastUsedTokens = + lastCall === undefined + ? undefined + : lastCall.inputTokens + lastCall.cacheReadTokens + (lastCall.outputTokens ?? 0); + return { + usedTokens: lastUsedTokens ?? usedTokens, + totalProcessedTokens, + inputTokens, + cachedInputTokens, + outputTokens, + ...(usage.thinkingTokens !== undefined ? { reasoningOutputTokens: usage.thinkingTokens } : {}), + ...(lastCall !== undefined && lastUsedTokens !== undefined + ? { + lastUsedTokens, + lastInputTokens: lastCall.inputTokens, + lastCachedInputTokens: lastCall.cacheReadTokens, + lastOutputTokens: lastCall.outputTokens ?? 0, + } + : {}), + compactsAutomatically: true, + }; +} + +function droidTokenUsageSnapshotsEqual( + left: ThreadTokenUsageSnapshot | undefined, + right: ThreadTokenUsageSnapshot, +): boolean { + return ( + left !== undefined && + left.usedTokens === right.usedTokens && + left.totalProcessedTokens === right.totalProcessedTokens && + left.inputTokens === right.inputTokens && + left.cachedInputTokens === right.cachedInputTokens && + left.outputTokens === right.outputTokens && + left.reasoningOutputTokens === right.reasoningOutputTokens && + left.lastUsedTokens === right.lastUsedTokens && + left.lastInputTokens === right.lastInputTokens && + left.lastCachedInputTokens === right.lastCachedInputTokens && + left.lastOutputTokens === right.lastOutputTokens && + left.compactsAutomatically === right.compactsAutomatically + ); +} + +type DroidTurnOutcome = + | { readonly state: "completed"; readonly stopReason: string } + | { readonly state: "cancelled"; readonly stopReason: string } + | { readonly state: "failed"; readonly errorMessage: string }; + +export function droidTurnOutcomeForReason(reason: string | undefined): DroidTurnOutcome { + switch (reason) { + case undefined: + case "completed": + // A spec handoff is droid finishing planning and forking into the + // implementation session; the turn succeeded. + case "spec_handoff": + return { state: "completed", stopReason: reason ?? "completed" }; + case "cancelled": + return { state: "cancelled", stopReason: reason }; + case "permission_rejected": + case "prompt_rejected": + return { state: "cancelled", stopReason: reason }; + case "model_authentication_failed": + return { + state: "failed", + errorMessage: "Droid is not authenticated. Run `droid` in a terminal to sign in.", + }; + case "model_usage_exhausted": + return { state: "failed", errorMessage: "Droid model usage is exhausted." }; + case "no_approver_available": + return { state: "failed", errorMessage: "Droid required an approval no client answered." }; + default: + return { state: "failed", errorMessage: `Droid turn ended with reason '${reason}'.` }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseDroidResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== DROID_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +const decodeAskUserRequest = Schema.decodeUnknownEffect(DroidAskUserRequest); +const decodeInitializeResult = Schema.decodeUnknownEffect(DroidInitializeSessionResult); +const decodeLoadResult = Schema.decodeUnknownEffect(DroidLoadSessionResult); +const decodeExecuteRewindResult = Schema.decodeUnknownEffect(DroidExecuteRewindResult); + +export function makeDroidAdapter(droidSettings: DroidSettings, options?: DroidAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("droid"); + const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const sessions = new Map(); + // stopAll coordination: reject new starts while closing, and remember + // threads whose startSession is still in flight (not yet in `sessions`). + let closing = false; + const startingThreads = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + options?.registerDebugStateReader?.((threadId) => + SynchronizedRef.get(threadLocksRef).pipe( + Effect.map((locks) => ({ + threadLockCount: locks.size, + threadLockReferenceCount: locks.get(threadId)?.references ?? 0, + interruptedTurnCount: sessions.get(threadId)?.interruptedTurnIds.size ?? 0, + })), + ), + ); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Droid runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const acquireThreadSemaphore = (threadId: ThreadId) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing !== undefined) { + const next = new Map(current); + next.set(threadId, { ...existing, references: existing.references + 1 }); + return Effect.succeed([existing.semaphore, next] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, { semaphore, references: 1 }); + return [semaphore, next] as const; + }), + ); + }); + + const releaseThreadSemaphore = (threadId: ThreadId) => + SynchronizedRef.update(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing === undefined) return current; + const next = new Map(current); + if (existing.references === 1 && !sessions.has(threadId)) { + next.delete(threadId); + } else { + next.set(threadId, { ...existing, references: existing.references - 1 }); + } + return next; + }); + + const withThreadLock = (threadId: ThreadId, effect: Effect.Effect) => + Effect.acquireUseRelease( + acquireThreadSemaphore(threadId), + (semaphore) => semaphore.withPermit(effect), + () => releaseThreadSemaphore(threadId), + ); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Droid notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const requestViaRpc = ( + ctx: DroidSessionContext, + method: string, + params: unknown, + requestOptions?: { readonly timeoutMs?: number | undefined }, + ) => + ctx.rpc.request(method, params, requestOptions).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: cause.message, + cause, + }), + ), + ); + + /** Close an open runtime item, if any, so clients never see a stuck row. */ + const completeOpenItem = ( + ctx: DroidSessionContext, + itemId: string, + itemType: "assistant_message" | "reasoning", + turnId: TurnId, + ) => + Effect.gen(function* () { + if (!ctx.openItemIds.delete(itemId)) return; + yield* offerRuntimeEvent({ + type: "item.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(itemId), + payload: { itemType, status: "completed" }, + }); + }); + + const emitStreamedDelta = ( + ctx: DroidSessionContext, + turnId: TurnId, + input: { + readonly itemId: string; + readonly itemType: "assistant_message" | "reasoning"; + readonly streamKind: "assistant_text" | "reasoning_text"; + readonly delta: string; + }, + ) => + Effect.gen(function* () { + if (!ctx.openItemIds.has(input.itemId)) { + ctx.openItemIds.add(input.itemId); + yield* offerRuntimeEvent({ + type: "item.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(input.itemId), + payload: { itemType: input.itemType, status: "inProgress" }, + }); + } + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(input.itemId), + payload: { streamKind: input.streamKind, delta: input.delta }, + }); + }); + + const emitTokenUsage = ( + ctx: DroidSessionContext, + usage: DroidTokenUsage, + lastCall?: DroidLastCallTokenUsage, + ) => + Effect.gen(function* () { + const snapshot = droidTokenUsageSnapshot(usage, lastCall); + if (droidTokenUsageSnapshotsEqual(ctx.lastEmittedTokenUsage, snapshot)) { + return; + } + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { usage: snapshot }, + }); + ctx.lastEmittedTokenUsage = snapshot; + }); + + const completeAllOpenItems = ( + ctx: DroidSessionContext, + turnId: TurnId, + outcome: DroidTurnOutcome, + ) => + Effect.forEach( + Array.from(ctx.openItemIds), + (itemId) => { + const assistantMessage = itemId.startsWith("msg:"); + const reasoning = itemId.startsWith("reasoning:"); + const toolName = assistantMessage || reasoning ? undefined : ctx.toolUseNames.get(itemId); + return Effect.gen(function* () { + yield* offerRuntimeEvent({ + type: "item.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(itemId), + payload: { + itemType: assistantMessage + ? "assistant_message" + : reasoning + ? "reasoning" + : toolName + ? droidToolLifecycleItemType(toolName) + : "dynamic_tool_call", + status: + assistantMessage || reasoning || outcome.state === "completed" + ? "completed" + : "failed", + ...(toolName ? { title: toolName } : {}), + }, + }); + ctx.openItemIds.delete(itemId); + }); + }, + { discard: true }, + ); + + const completeAllOpenChildTasks = (ctx: DroidSessionContext) => + Effect.gen(function* () { + const childSessions = Array.from(ctx.childSessions); + ctx.childSessions.clear(); + yield* Effect.forEach( + childSessions, + ([sessionId, child]) => + makeEventStamp().pipe( + Effect.flatMap((stamp) => + offerRuntimeEvent({ + type: "task.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId ?? ctx.session.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(sessionId), + status: "stopped", + summary: child.description, + }, + }), + ), + ), + { discard: true }, + ); + }); + + /** + * Terminal settlement for a turn. Emits exactly one turn.completed; late + * completions for interrupted or already-settled turns are dropped. + */ + const settleTurn = (ctx: DroidSessionContext, turnId: TurnId, outcome: DroidTurnOutcome) => + Effect.gen(function* () { + // A pre-marked interrupt outranks any non-cancelled terminal: consume + // the mark and drop the notification so the pending cancellation + // settles the turn instead. Cancelled outcomes pass through; the + // active-turn guard below still prevents a second terminal event. + if (ctx.interruptedTurnIds.has(turnId) && outcome.state !== "cancelled") { + ctx.interruptedTurnIds.delete(turnId); + return; + } + if (ctx.activeTurnId !== turnId && ctx.session.activeTurnId !== turnId) { + // Late cancelled terminal for an already-settled turn retires its mark. + if (outcome.state === "cancelled") ctx.interruptedTurnIds.delete(turnId); + return; + } + yield* completeAllOpenItems(ctx, turnId, outcome); + ctx.toolUseNames.clear(); + ctx.pendingTurnMessageIds.clear(); + ctx.persistedPendingTurnMessageIds.clear(); + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { ...readySession, status: "ready", updatedAt: yield* nowIso }; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: + outcome.state === "failed" + ? { state: "failed", errorMessage: outcome.errorMessage } + : { state: outcome.state, stopReason: outcome.stopReason }, + }); + }); + + const handleTurnCompleted = ( + ctx: DroidSessionContext, + notification: Extract, + ) => + withThreadLock( + ctx.threadId, + Effect.gen(function* () { + const live = sessions.get(ctx.threadId); + if (!live || live.stopped || live.droidSessionId !== ctx.droidSessionId) return; + const turnId = live.activeTurnId ?? live.session.activeTurnId; + yield* emitTokenUsage( + live, + notification.cumulativeTokenUsage ?? notification.tokenUsage, + live.lastCallTokenUsage, + ); + // The spec session hands off to the implementation session it + // spawned; from here on the successor is the conversation. + if (notification.reason === "spec_handoff" && live.specSuccessorSessionId !== undefined) { + live.droidSessionId = live.specSuccessorSessionId; + live.specSuccessorSessionId = undefined; + live.session = { + ...live.session, + resumeCursor: { + schemaVersion: DROID_RESUME_VERSION, + sessionId: live.droidSessionId, + }, + }; + } + if (notification.turnId !== undefined) { + live.pendingTurnMessageIds.delete(notification.turnId); + live.persistedPendingTurnMessageIds.delete(notification.turnId); + if (live.pendingTurnMessageIds.size > 0) { + // Factory CLI emits one live terminal for a physical run, keyed + // by its opening message id. Steers drained into that run emit + // create_message first and receive only durable outcome records; + // steers left queued run later with their own message-id terminal + // (sharedAgentRunner.ts and AgentLoop.ts queued-message contract). + const allRemainingWereCoalesced = Array.from(live.pendingTurnMessageIds).every( + (messageId) => live.persistedPendingTurnMessageIds.has(messageId), + ); + if (!allRemainingWereCoalesced) return; + live.pendingTurnMessageIds.clear(); + live.persistedPendingTurnMessageIds.clear(); + } + } else { + live.pendingTurnMessageIds.clear(); + live.persistedPendingTurnMessageIds.clear(); + } + if (turnId === undefined) return; + yield* settleTurn(live, turnId, droidTurnOutcomeForReason(notification.reason)); + }), + ); + + const handleNotification = (ctx: DroidSessionContext, notification: DroidSessionNotification) => + Effect.gen(function* () { + if (ctx.stopped) return; + if (notification.type === "agent_turn_completed") { + return yield* handleTurnCompleted(ctx, notification); + } + if (notification.type === "session_compacted") { + // Compaction keeps the same droid session id (verified in + // factory-mono); only the context meter moves, and that arrives via + // session_token_usage_changed. + yield* logNative(ctx.threadId, "droid.session_compacted", notification); + return; + } + if (notification.type === "child_session_available") { + const description = + notification.description ?? notification.subagentType ?? "Droid subagent"; + ctx.childSessions.set(notification.childSessionId, { description }); + yield* offerRuntimeEvent({ + type: "task.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(notification.childSessionId), + description, + }, + }); + return; + } + if (notification.type === "session_token_usage_changed") { + if (notification.lastCallTokenUsage !== undefined) { + ctx.lastCallTokenUsage = notification.lastCallTokenUsage; + } + yield* emitTokenUsage( + ctx, + notification.inclusiveTokenUsage ?? notification.tokenUsage, + ctx.lastCallTokenUsage, + ); + return; + } + if (notification.type === "session_title_updated") { + yield* offerRuntimeEvent({ + type: "thread.metadata.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { name: notification.title }, + }); + return; + } + if (notification.type === "error") { + yield* offerRuntimeEvent({ + type: "runtime.error", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { message: notification.message, class: "provider_error" }, + }); + return; + } + if (notification.type === "llm_retry") { + yield* offerRuntimeEvent({ + type: "runtime.warning", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + message: `Droid is retrying the model request (attempt ${notification.attempt}).`, + }, + }); + return; + } + if (notification.type === "create_message") { + if ( + isRecord(notification.message) && + typeof notification.message.id === "string" && + (notification.message.role === "user" || + notification.message.type === "user_message") && + ctx.pendingTurnMessageIds.has(notification.message.id) + ) { + ctx.persistedPendingTurnMessageIds.add(notification.message.id); + } + const turnId = ctx.activeTurnId; + if (turnId !== undefined) { + const existing = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existing + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, notification.message] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [notification.message] }]; + } + return; + } + + // Everything below streams inside a turn; drop stragglers with no + // active turn or an interrupted one (Grok precedent). + const turnId = ctx.activeTurnId; + if (turnId === undefined || ctx.interruptedTurnIds.has(turnId)) return; + + switch (notification.type) { + case "assistant_text_delta": + yield* emitStreamedDelta(ctx, turnId, { + itemId: `msg:${notification.messageId}`, + itemType: "assistant_message", + streamKind: "assistant_text", + delta: notification.textDelta, + }); + return; + case "assistant_text_complete": + yield* completeOpenItem( + ctx, + `msg:${notification.messageId}`, + "assistant_message", + turnId, + ); + return; + case "thinking_text_delta": + yield* emitStreamedDelta(ctx, turnId, { + itemId: `reasoning:${notification.messageId}`, + itemType: "reasoning", + streamKind: "reasoning_text", + delta: notification.textDelta, + }); + return; + case "thinking_text_complete": + yield* completeOpenItem( + ctx, + `reasoning:${notification.messageId}`, + "reasoning", + turnId, + ); + return; + case "tool_call": { + const toolUse = notification.toolUse; + yield* logNative(ctx.threadId, "droid.tool_call", toolUse); + ctx.openItemIds.add(toolUse.id); + yield* offerRuntimeEvent({ + type: "item.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(toolUse.id), + payload: { + itemType: droidToolLifecycleItemType(toolUse.name), + status: "inProgress", + title: toolUse.name, + ...(toolUse.input !== undefined ? { data: toolUse.input } : {}), + }, + }); + return; + } + case "tool_result": { + const title = ctx.toolUseNames.get(notification.toolUseId); + ctx.openItemIds.delete(notification.toolUseId); + yield* offerRuntimeEvent({ + type: "item.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: RuntimeItemId.make(notification.toolUseId), + payload: { + itemType: title ? droidToolLifecycleItemType(title) : "dynamic_tool_call", + status: notification.isError ? "failed" : "completed", + ...(title ? { title } : {}), + }, + }); + ctx.toolUseNames.delete(notification.toolUseId); + return; + } + case "tool_progress_update": { + const taskId = notification.update.subagentSessionId?.trim(); + if (!taskId) { + // Ingestion discards taskless progress, and the parent + // conversation's item lifecycle already covers its tools. + return; + } + const toolUseId = notification.toolUseId.trim(); + const toolName = notification.toolName.trim(); + const summary = [ + notification.update.text, + notification.update.details, + notification.update.error, + notification.update.status, + notification.update.valueSnippet, + ] + .map((value) => value?.trim()) + .find((value): value is string => value !== undefined && value.length > 0); + // Do not gate on childSessions: progress can arrive before + // child_session_available, and its session id already owns it. + yield* offerRuntimeEvent({ + type: "tool.progress", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { + taskId: RuntimeTaskId.make(taskId), + ...(toolUseId ? { toolUseId } : {}), + ...(toolName ? { toolName } : {}), + ...(summary ? { summary } : {}), + }, + }); + return; + } + default: + // Unknown and internal notification types (heartbeats, working + // state, mission traffic) are intentionally ignored. + return; + } + }); + + // Notifications from a session id that is neither the live session nor a + // known child are stragglers from an abandoned (pre-rewind, pre-compact) + // session and must not touch turn state. + const handleChildSessionNotification = ( + ctx: DroidSessionContext, + sessionId: string, + notification: DroidSessionNotification, + ) => + Effect.gen(function* () { + if (ctx.stopped) return; + const child = ctx.childSessions.get(sessionId); + if (!child) return; + if (notification.type === "agent_turn_completed") { + const outcome = droidTurnOutcomeForReason(notification.reason); + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + taskId: RuntimeTaskId.make(sessionId), + status: + outcome.state === "completed" + ? "completed" + : outcome.state === "cancelled" + ? "stopped" + : "failed", + summary: child.description, + }, + }); + ctx.childSessions.delete(sessionId); + } + }); + + // tool_result carries no tool name; remember tool_call names per session. + const rememberToolUse = (ctx: DroidSessionContext, toolUse: DroidToolUse) => { + ctx.toolUseNames.set(toolUse.id, toolUse.name); + }; + + const handlePermissionRequest = ( + ctx: DroidSessionContext, + request: Extract, + ) => + Effect.gen(function* () { + const params = request.params; + yield* logNative(ctx.threadId, "droid.request_permission", params.raw); + const primaryToolUse = params.toolUses[0]; + if (primaryToolUse) rememberToolUse(ctx, primaryToolUse.toolUse); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = ctx.activeTurnId; + ctx.pendingApprovals.set(requestId, { decision }); + const requestType = droidCanonicalRequestType(primaryToolUse?.confirmationType); + yield* offerRuntimeEvent({ + type: "request.opened", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { + requestType, + detail: + droidPermissionDetail(params) ?? + encodeJsonStringForDiagnostics(params.raw)?.slice(0, 2000) ?? + "[unserializable params]", + args: params.raw, + }, + raw: { + source: "droid.jsonrpc.request", + method: "droid.request_permission", + payload: params.raw, + }, + }); + const resolved = yield* Deferred.await(decision); + ctx.pendingApprovals.delete(requestId); + yield* offerRuntimeEvent({ + type: "request.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { requestType, decision: resolved }, + }); + const selectedOutcome = + resolved === "cancel" + ? "cancel" + : (selectDroidPermissionOutcome(params.options, resolved) ?? "cancel"); + yield* request.respond({ selectedOption: selectedOutcome }); + }); + + const handleAskUserRequest = (ctx: DroidSessionContext, request: DroidServerRequest) => + Effect.gen(function* () { + const params = yield* decodeAskUserRequest(request.params).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.ask_user", + detail: "Failed to decode Droid ask_user request.", + cause, + }), + ), + ); + yield* logNative(ctx.threadId, "droid.ask_user", request.params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const resolution = yield* Deferred.make(); + const turnId = ctx.activeTurnId; + ctx.pendingUserInputs.set(requestId, { resolution }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { + questions: params.questions.map((question) => ({ + id: String(question.index), + header: question.topic, + question: question.question, + options: question.options.map((option) => ({ label: option, description: option })), + multiSelect: question.multiSelect ?? false, + })), + }, + raw: { + source: "droid.jsonrpc.request", + method: "droid.ask_user", + payload: request.params, + }, + }); + const resolved = yield* Deferred.await(resolution); + ctx.pendingUserInputs.delete(requestId); + const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers: resolvedAnswers }, + }); + if (resolved._tag === "cancelled") { + yield* request.respond({ cancelled: true, answers: [] }); + return; + } + yield* request.respond({ + answers: params.questions.map((question) => { + const raw = resolved.answers[String(question.index)]; + const answer = Array.isArray(raw) ? raw.map(String).join(", ") : String(raw ?? ""); + return { index: question.index, question: question.question, answer }; + }), + }); + }); + + const handleServerRequest = (ctx: DroidSessionContext, request: DroidServerRequest) => { + const handler = + request.method === "droid.request_permission" + ? handlePermissionRequest(ctx, request) + : handleAskUserRequest(ctx, request); + // Each HITL request parks on a Deferred until a client answers, so it + // must not block the request stream; failures answer the RPC so the + // CLI never hangs on t3. + return handler.pipe( + Effect.catchCause((cause) => + Effect.logWarning("Droid server request handling failed.", { + cause, + method: request.method, + }).pipe( + Effect.andThen( + request.fail(-32603, "t3-code failed to process the request.").pipe(Effect.ignore), + ), + ), + ), + Effect.forkIn(ctx.scope), + ); + }; + + const stopSessionInternal = (ctx: DroidSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* completeAllOpenChildTasks(ctx); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: DroidAdapterShape["startSession"] = (input) => + Effect.suspend(() => { + if (closing) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Droid adapter is stopping; cannot start a new session.", + }), + ); + } + startingThreads.set(input.threadId, (startingThreads.get(input.threadId) ?? 0) + 1); + return startSessionLocked(input).pipe( + Effect.ensuring( + Effect.sync(() => { + const references = startingThreads.get(input.threadId); + if (references === undefined || references === 1) { + startingThreads.delete(input.threadId); + } else { + startingThreads.set(input.threadId, references - 1); + } + }), + ), + ); + }); + + const startSessionLocked = (input: Parameters[0]) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + const cwd = input.cwd.trim(); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModelId = modelSelection?.model; + const requestedEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); + + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseDroidResume(input.resumeCursor)?.sessionId; + const rpc = yield* makeDroidRpcClient({ + command: droidSettings.binaryPath, + args: ["exec", "--input-format", "stream-jsonrpc", "--output-format", "stream-jsonrpc"], + cwd, + ...(options?.environment ? { env: options.environment } : {}), + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const mcpServers = droidMcpServersParam(input.threadId); + const autonomyLevel = droidAutonomyLevelForRuntimeMode(input.runtimeMode); + + const requestSession = (method: string, params: unknown) => + rpc.request(method, params, { timeoutMs: SESSION_INIT_TIMEOUT_MS }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const decodeSessionResult = ( + decode: (value: unknown) => Effect.Effect, + value: unknown, + method: string, + ) => + decode(value).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: "Failed to decode Droid session result.", + cause, + }), + ), + ); + + const initialized = resumeSessionId + ? { + kind: "loaded" as const, + sessionId: resumeSessionId, + result: yield* decodeSessionResult( + decodeLoadResult, + yield* requestSession("droid.load_session", { + sessionId: resumeSessionId, + ...mcpServers, + }), + "droid.load_session", + ), + } + : { + kind: "initialized" as const, + result: yield* decodeSessionResult( + decodeInitializeResult, + yield* requestSession("droid.initialize_session", { + machineId: "default", + cwd, + autonomyLevel, + interactionMode: "auto", + ...(requestedModelId ? { modelId: requestedModelId } : {}), + ...(requestedEffort ? { reasoningEffort: requestedEffort } : {}), + ...(input.title ? { title: input.title } : {}), + ...mcpServers, + }), + "droid.initialize_session", + ), + }; + const droidSessionId = + initialized.kind === "loaded" ? initialized.sessionId : initialized.result.sessionId; + + // A loaded session keeps its persisted settings; re-assert t3's + // autonomy, reset interaction to ordinary auto mode, and apply any + // requested model so the first resumed turn uses the requested mode. + if (initialized.kind === "loaded") { + yield* requestSession("droid.update_session_settings", { + autonomyLevel, + interactionMode: "auto", + ...(requestedModelId ? { modelId: requestedModelId } : {}), + ...(requestedEffort ? { reasoningEffort: requestedEffort } : {}), + }); + } + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(requestedModelId ? { model: requestedModelId } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: DROID_RESUME_VERSION, + sessionId: droidSessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: DroidSessionContext = { + threadId: input.threadId, + droidSessionId, + session, + scope: sessionScope, + rpc, + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + // Durable Droid user messages do not identify which ones were + // steers coalesced into an earlier t3 turn. Only turns opened by + // this process are safe rewind anchors; resumed rollback fails + // loudly rather than guessing at a user-message boundary. + turns: [], + activeTurnId: undefined, + interruptedTurnIds: new Set(), + pendingTurnMessageIds: new Set(), + persistedPendingTurnMessageIds: new Set(), + openItemIds: new Set(), + toolUseNames: new Map(), + childSessions: new Map(), + specSuccessorSessionId: undefined, + lastCallTokenUsage: + initialized.kind === "loaded" ? initialized.result.lastCallTokenUsage : undefined, + lastEmittedTokenUsage: undefined, + currentModelId: requestedModelId, + currentReasoningEffort: requestedEffort, + currentInteractionMode: "auto", + stopped: false, + }; + + yield* Stream.runDrain( + Stream.mapEffect(rpc.notifications, (envelope) => + Effect.gen(function* () { + const notification = envelope.notification; + // The envelope session id is the rewind guard: only the live + // droid session's notifications reach turn handling. Known + // child sessions become task lifecycles; a spec handoff's + // implementation successor streams into the same t3 turn. + if (envelope.sessionId !== undefined && envelope.sessionId !== ctx.droidSessionId) { + if (ctx.childSessions.has(envelope.sessionId)) { + return yield* handleChildSessionNotification( + ctx, + envelope.sessionId, + notification, + ); + } + const isSpecSuccessor = + ctx.activeTurnId !== undefined && + ctx.currentInteractionMode === "spec" && + (ctx.specSuccessorSessionId === undefined || + ctx.specSuccessorSessionId === envelope.sessionId); + if (!isSpecSuccessor) { + return yield* Effect.logDebug( + "Dropped Droid notification from an abandoned session.", + { sessionId: envelope.sessionId, type: notification.type }, + ); + } + ctx.specSuccessorSessionId = envelope.sessionId; + } + if (notification.type === "tool_call") rememberToolUse(ctx, notification.toolUse); + yield* handleNotification(ctx, notification); + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Droid runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber: children of + // startSession are interrupted when it returns (see the Grok + // adapter's war story). + Effect.forkIn(ctx.scope), + ); + + // handleServerRequest forks each HITL exchange and answers the RPC + // on failure, so draining the request stream itself cannot fail. + yield* Stream.runDrain( + Stream.mapEffect(rpc.serverRequests, (request) => + Effect.asVoid(handleServerRequest(ctx, request)), + ), + ).pipe(Effect.forkIn(ctx.scope)); + + // Unexpected process death fails the active turn and tears the + // session down so the UI never waits on a corpse. + yield* rpc.exits.pipe( + Effect.flatMap((exit) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + // Identity check, not a session-id compare: rewind mints a + // successor droid session id on this same process, and a + // stale watcher must not tear down a replacement session. + const live = sessions.get(input.threadId); + if (live !== ctx || live.stopped) return; + live.stopped = true; + yield* completeAllOpenChildTasks(live); + const activeTurnId = live.activeTurnId ?? live.session.activeTurnId; + if (activeTurnId !== undefined) { + yield* settleTurn(live, activeTurnId, { + state: "failed", + errorMessage: `Droid exited unexpectedly (${exit.description}).`, + }); + } + yield* settlePendingApprovalsAsCancelled(live.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(live.pendingUserInputs); + sessions.delete(input.threadId); + // Close the scope first so notification/request fibers are + // quiesced and nothing ordinary can publish after the + // terminal session event. Closing our own scope is safe: + // the forkIn finalizer skips interrupting the closing fiber. + yield* Effect.ignore(Scope.close(live.scope, Exit.void)); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { exitKind: "error" }, + }); + }), + ), + ), + Effect.catch((cause) => + Effect.logError("Failed to process Droid process exit.", { cause }), + ), + Effect.forkIn(ctx.scope), + ); + + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: initialized.kind === "loaded" }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Droid session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: droidSessionId }, + }); + if (initialized.kind === "loaded") { + // Resumed threads show the real context meter before the first + // turn instead of an empty gauge. + const usage = initialized.result.inclusiveTokenUsage ?? initialized.result.tokenUsage; + if (usage) yield* emitTokenUsage(ctx, usage, ctx.lastCallTokenUsage); + } + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: DroidAdapterShape["sendTurn"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const text = input.input?.trim(); + const attachments = input.attachments ?? []; + if (!text && attachments.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModelId = modelSelection?.model; + const requestedEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); + const requestedInteractionMode = input.interactionMode === "plan" ? "spec" : "auto"; + const settingsPatch = { + ...(requestedModelId && requestedModelId !== ctx.currentModelId + ? { modelId: requestedModelId } + : {}), + ...(requestedEffort && requestedEffort !== ctx.currentReasoningEffort + ? { reasoningEffort: requestedEffort } + : {}), + ...(requestedInteractionMode !== ctx.currentInteractionMode + ? { interactionMode: requestedInteractionMode } + : {}), + }; + if (Object.keys(settingsPatch).length > 0) { + yield* requestViaRpc(ctx, "droid.update_session_settings", settingsPatch); + ctx.currentModelId = requestedModelId ?? ctx.currentModelId; + ctx.currentReasoningEffort = requestedEffort ?? ctx.currentReasoningEffort; + ctx.currentInteractionMode = requestedInteractionMode; + } + + const images = yield* Effect.forEach(attachments, (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.add_user_message", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.add_user_message", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "base64" as const, + data: Buffer.from(bytes).toString("base64"), + mediaType: attachment.mimeType, + }; + }), + ); + + const messageId = yield* randomUUIDv4; + const steeringTurnId = ctx.pendingTurnMessageIds.size > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(messageId); + ctx.pendingTurnMessageIds.add(messageId); + ctx.activeTurnId = turnId; + const displayModel = ctx.currentModelId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + ctx.lastEmittedTokenUsage = undefined; + // Track the turn here, not from create_message notifications, so + // rewind anchoring stays 1:1 with t3's turn count. + ctx.turns.push({ id: turnId, items: [] }); + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + ...(displayModel ? { model: displayModel } : {}), + ...(ctx.currentReasoningEffort ? { effort: ctx.currentReasoningEffort } : {}), + }, + }); + } + + yield* requestViaRpc(ctx, "droid.add_user_message", { + messageId, + ...(text ? { text } : { text: "" }), + ...(images.length > 0 ? { images } : {}), + }).pipe( + Effect.tapError(() => + Effect.gen(function* () { + ctx.pendingTurnMessageIds.delete(messageId); + ctx.persistedPendingTurnMessageIds.delete(messageId); + if (steeringTurnId === undefined && ctx.pendingTurnMessageIds.size === 0) { + // A rejected opening message never became a droid turn, so + // it must not count toward rewind anchoring either. + ctx.turns = ctx.turns.filter((turn) => turn.id !== turnId); + yield* settleTurn(ctx, turnId, { + state: "failed", + errorMessage: "Droid rejected the user message.", + }); + } + }), + ), + ); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + + const interruptTurn: DroidAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + // Mark before waiting for the thread lock so cancellation wins races + // against a completion notification already queued on the lock: + // settleTurn consumes the mark and drops that completion. + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { _tag: "Proceed" as const, interruptedTurnId: turnId }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { _tag: "Proceed" as const, interruptedTurnId }; + }); + if (observed._tag === "Ignore") return; + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + const interruptedTurnId = observed.interruptedTurnId ?? activeTurnId; + if ( + interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== interruptedTurnId + ) { + return; + } + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore(requestViaRpc(ctx, "droid.interrupt_session", {})); + if (interruptedTurnId !== undefined) { + // Settle immediately; the late cancelled completion notification + // is dropped by settleTurn's cleared-active-turn guard. + yield* settleTurn(ctx, interruptedTurnId, { + state: "cancelled", + stopReason: "cancelled", + }); + ctx.interruptedTurnIds.delete(interruptedTurnId); + } + }), + ); + }); + + const respondToRequest: DroidAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + ctx.pendingApprovals.delete(requestId); + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: DroidAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.ask_user", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + ctx.pendingUserInputs.delete(requestId); + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); + }); + + const readThread: DroidAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + // Rewind forks the droid session before the first discarded user message + // (t3 turn ids are those message ids) and re-anchors the live process on + // the fork. File arrays stay empty: t3's checkpoint refs own filesystem + // restoration, droid only rolls conversation state back. + const rollbackThread: DroidAdapterShape["rollbackThread"] = (threadId, numTurns) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + if (ctx.activeTurnId !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Cannot roll back while a turn is running.", + }); + } + const anchorIndex = ctx.turns.length - numTurns; + const anchor = ctx.turns[anchorIndex]; + if (anchorIndex < 0 || anchor === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.execute_rewind", + detail: `Cannot roll back ${numTurns} turn(s); only ${ctx.turns.length} tracked in this session.`, + }); + } + const rewound = yield* decodeExecuteRewindResult( + yield* requestViaRpc(ctx, "droid.execute_rewind", { + sessionId: ctx.droidSessionId, + messageId: String(anchor.id), + filesToRestore: [], + filesToDelete: [], + forkTitle: "T3 Code checkpoint revert", + }), + ).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "droid.execute_rewind", + detail: "Failed to decode Droid rewind result.", + cause, + }), + ), + }), + ); + // execute_rewind preserves the current session; the live process + // must load the fork to continue on the rewound conversation. + yield* requestViaRpc(ctx, "droid.load_session", { + sessionId: rewound.newSessionId, + ...droidMcpServersParam(threadId), + }); + ctx.droidSessionId = rewound.newSessionId; + ctx.turns = ctx.turns.slice(0, anchorIndex); + ctx.session = { + ...ctx.session, + resumeCursor: { + schemaVersion: DROID_RESUME_VERSION, + sessionId: rewound.newSessionId, + }, + updatedAt: yield* nowIso, + }; + return { threadId, turns: ctx.turns }; + }), + ); + + const stopSession: DroidAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: DroidAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: DroidAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + // stopAll must also cover sessions still inside startSession: the gate + // rejects new starts while in-flight ones are serialized through their + // thread locks, so no live droid process can outlast the sweep. + const stopAll: DroidAdapterShape["stopAll"] = () => + Effect.suspend(() => { + closing = true; + const threadIds = new Set([...sessions.keys(), ...startingThreads.keys()]); + return Effect.forEach( + threadIds, + (threadId) => + withThreadLock( + threadId, + Effect.suspend(() => { + const ctx = sessions.get(threadId); + return ctx ? stopSessionInternal(ctx) : Effect.void; + }), + ), + { discard: true }, + ).pipe( + Effect.ensuring( + Effect.sync(() => { + closing = false; + }), + ), + ); + }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies DroidAdapterShape; + }); +} + +/** + * Droid takes MCP servers as an array of named configs with {name, value} + * header pairs (factory-mono HttpMcpSchema). Recomputed per call so loads and + * rewinds pick up the current t3 MCP endpoint and credential, not the ones + * from initialization. + */ +function droidMcpServersParam(threadId: ThreadId) { + const mcpSession = McpProviderSession.readMcpProviderSession(threadId); + return mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [{ name: "Authorization", value: mcpSession.authorizationHeader }], + }, + ], + } + : {}; +} + +/** + * The human-readable summary a client shows next to the approval buttons. + * Full structured detail (diff contents, plan text, per-file patches) rides + * along untouched in the event's `args`/raw payload. + */ +function droidPermissionDetail(params: DroidPermissionRequest): string | undefined { + const primary = params.toolUses[0]; + if (!primary) return undefined; + const details = primary.details; + switch (details.type) { + case "exec": + return details.fullCommand.trim() || details.command; + case "edit": + return details.filePath; + case "create": + return details.filePath; + case "apply_patch": { + const files = details.files?.map((file) => file.filePath); + return files && files.length > 0 ? files.join("\n") : details.filePath; + } + case "exit_spec_mode": + return details.title ? `${details.title}\n\n${details.plan}` : details.plan; + case "propose_mission": + return details.title ? `${details.title}\n\n${details.proposal}` : details.proposal; + case "start_mission_run": + return `Start a mission run (${details.runningMissionCount} already running).`; + case "mcp_tool": + return details.serverName + ? `${details.serverName}: ${details.actualToolName ?? details.toolName}` + : details.toolName; + case "ask_user": + return details.questionnaire; + case "sandbox_violation": + return `${details.violatingToolName} attempted a ${details.operationType} of ${details.target}: ${details.reason}`; + case "droid_shield_violation": + return `${details.command}\n${details.reason}`; + } +} 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 000000000000..1f0b33646f0d --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.test.ts @@ -0,0 +1,476 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DroidSettings } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import type { DroidCommandInfo, DroidModelInfo, DroidSkillInfo } from "../droid/DroidProtocol.ts"; +import { DroidModelInfo as DroidModelInfoSchema } from "../droid/DroidProtocol.ts"; +import { + buildDroidDiscoveredModels, + buildDroidSkills, + buildDroidSlashCommands, + checkDroidProviderStatus, + detectDroidAuth, +} from "./DroidProvider.ts"; + +const decodeModelInfo = Schema.decodeUnknownSync(DroidModelInfoSchema); +const decodeDroidSettings = Schema.decodeSync(DroidSettings); + +const makeInventoryProbeBinary = Effect.fn("makeInventoryProbeBinary")(function* ( + mode: "concurrent" | "commands-error" | "malformed-model", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-droid-provider-", + }); + const binaryPath = path.join(directory, "droid"); + const script = `#!/usr/bin/env node +const readline = require("node:readline"); + +if (process.argv[2] === "--version") { + process.stdout.write("droid 0.200.0\\n"); + process.exit(0); +} + +const mode = "${mode}"; +const pending = new Map(); +let releasedRequestCount; + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", type: "response", ...message }) + "\\n"); +} + +function resultFor(method) { + switch (method) { + case "droid.list_models": + return { + models: [ + { + id: mode === "concurrent" ? "concurrent-" + releasedRequestCount : "discovered-model", + displayName: "Discovered Model", + ...(mode === "malformed-model" + ? {} + : { + shortDisplayName: "Discovered", + modelProvider: "factory", + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "medium" + }) + } + ] + }; + case "droid.list_commands": + return { commands: [{ name: "review", description: "Review changes" }] }; + case "droid.list_skills": + return { + skills: [ + { + name: "verify", + filePath: "/skills/verify/SKILL.md", + location: "personal", + enabled: true + } + ] + }; + } +} + +function respond(request) { + if (mode === "commands-error" && request.method === "droid.list_commands") { + write({ id: request.id, error: { code: -32603, message: "command inventory failed" } }); + return; + } + write({ id: request.id, result: resultFor(request.method) }); +} + +function handle(request) { + if (mode !== "concurrent") { + respond(request); + return; + } + if (releasedRequestCount !== undefined) { + respond(request); + return; + } + pending.set(request.id, request); + if (pending.size === 1) { + setImmediate(() => { + releasedRequestCount = pending.size; + for (const pendingRequest of pending.values()) respond(pendingRequest); + pending.clear(); + }); + } +} + +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on("line", (line) => handle(JSON.parse(line))); +input.once("close", () => process.exit(0)); +`; + yield* fileSystem.writeFileString(binaryPath, script); + yield* fileSystem.chmod(binaryPath, 0o755); + return binaryPath; +}); + +describe("buildDroidDiscoveredModels", () => { + it("keeps enabled models, dedupes ids, and falls back to the id for a display name", () => { + const models: ReadonlyArray = [ + { + id: "claude-opus-5", + displayName: "Claude Opus 5", + shortDisplayName: "Opus 5", + modelProvider: "anthropic", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + }, + { + id: "claude-opus-5", + displayName: "Duplicate", + shortDisplayName: "Duplicate", + modelProvider: "anthropic", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + }, + { + id: " gpt-5-6-luna ", + displayName: " ", + shortDisplayName: "Luna", + modelProvider: "openai", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + }, + { + id: "retired-model", + displayName: "Retired", + shortDisplayName: "Retired", + modelProvider: "anthropic", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + disabled: true, + }, + ]; + + assert.deepEqual(buildDroidDiscoveredModels(models), [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + shortName: "Opus 5", + isCustom: false, + isDefault: true, + capabilities: { optionDescriptors: [] }, + }, + { + slug: "gpt-5-6-luna", + name: "gpt-5-6-luna", + shortName: "Luna", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ]); + }); + + it("marks Droid's configured default independently of discovery order", () => { + const models = buildDroidDiscoveredModels([ + { + id: "gpt-5-6-luna", + displayName: "GPT-5.6 Luna", + shortDisplayName: "Luna", + modelProvider: "openai", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + }, + { + id: "claude-opus-5", + displayName: "Claude Opus 5", + shortDisplayName: "Opus 5", + modelProvider: "anthropic", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + }, + ]); + + assert.equal(models[0]?.isDefault, undefined); + assert.equal(models[1]?.isDefault, true); + }); + + it("surfaces Droid's own custom models as ordinary probe models", () => { + // Verbatim `droid.list_models` shape for a BYOK entry, `isCustom` included. + const model = decodeModelInfo({ + id: "custom:factory://kimi-k3", + displayName: "factory://kimi-k3", + shortDisplayName: "Kimi K3", + modelProvider: "generic-chat-completion-api", + supportedReasoningEfforts: [], + defaultReasoningEffort: "none", + isCustom: true, + noImageSupport: false, + disabled: false, + }); + + assert.deepEqual(buildDroidDiscoveredModels([model]), [ + { + slug: "custom:factory://kimi-k3", + name: "factory://kimi-k3", + shortName: "Kimi K3", + // T3 renders `isCustom` rows from its own custom-model config, so a true + // here would hide the model from the provider's Models section. + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ]); + }); + + it("exposes supported reasoning efforts as a select option descriptor", () => { + const [model] = buildDroidDiscoveredModels([ + { + id: "gpt-5-6-luna", + displayName: "GPT-5.6 Luna", + shortDisplayName: "Luna", + modelProvider: "openai", + supportedReasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, + ]); + + assert.deepEqual(model?.capabilities?.optionDescriptors, [ + { + id: "reasoningEffort", + label: "Reasoning effort", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "low" }, + { id: "high", label: "high", isDefault: true }, + ], + }, + ]); + }); +}); + +describe("buildDroidSlashCommands", () => { + it("maps argument hints to command input, drops blanks, dedupes, and sorts by name", () => { + const commands: ReadonlyArray = [ + { name: "review", description: "Review the diff", argumentHint: "" }, + { name: "deploy", description: " " }, + { name: "review", description: "Shadowed duplicate" }, + { name: " ", description: "Nameless" }, + { name: "release", description: "Cut a release", argumentHint: " " }, + ]; + + assert.deepEqual(buildDroidSlashCommands(commands), [ + { name: "deploy" }, + { name: "release", description: "Cut a release" }, + { name: "review", description: "Review the diff", input: { hint: "" } }, + ]); + }); +}); + +describe("buildDroidSkills", () => { + it("keeps user-invocable skills, carries disabled state, and maps location to scope", () => { + const skills: ReadonlyArray = [ + { + name: "voice", + description: "Write like a human.", + location: "personal", + filePath: "/home/dev/.factory/skills/voice/SKILL.md", + enabled: true, + userInvocable: true, + }, + { + name: "open-pr", + location: "project", + filePath: "/repo/.agents/skills/open-pr/SKILL.md", + enabled: false, + }, + { + name: "runtime-internal", + location: "builtin", + filePath: "/opt/droid/skills/runtime/SKILL.md", + userInvocable: false, + }, + ]; + + assert.deepEqual(buildDroidSkills(skills), [ + { + name: "open-pr", + path: "/repo/.agents/skills/open-pr/SKILL.md", + enabled: false, + scope: "project", + }, + { + name: "voice", + path: "/home/dev/.factory/skills/voice/SKILL.md", + enabled: true, + scope: "personal", + description: "Write like a human.", + shortDescription: "Write like a human.", + }, + ]); + }); + + it("treats a skill with no explicit invocability as user-invocable", () => { + const skills = buildDroidSkills([ + { name: "spec", location: "personal", filePath: "/skills/spec/SKILL.md" }, + ]); + + assert.deepEqual(skills, [ + { + name: "spec", + path: "/skills/spec/SKILL.md", + enabled: true, + scope: "personal", + }, + ]); + }); +}); + +it.layer(NodeServices.layer)("detectDroidAuth", (it) => { + it.effect("reports an API key without touching the filesystem", () => + Effect.gen(function* () { + const auth = yield* detectDroidAuth({ FACTORY_API_KEY: "fk-live", HOME: "/nonexistent" }); + + assert.deepEqual(auth, { status: "authenticated", type: "api-key", label: "API key" }); + }), + ); + + // FACTORY_HOME_OVERRIDE replaces the *user home*, so the credential lives at + // /.factory — not at itself. Getting this wrong reports a + // signed-in user as unauthenticated. + it.effect("resolves the stored login under FACTORY_HOME_OVERRIDE's .factory directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-droid-auth-" }); + yield* fs.makeDirectory(path.join(home, ".factory"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".factory", "auth.v2.keyring"), "{}"); + + const auth = yield* detectDroidAuth({ FACTORY_HOME_OVERRIDE: home, HOME: "/nonexistent" }); + + assert.deepEqual(auth, { + status: "authenticated", + type: "oauth", + label: "Factory account", + }); + }), + ); + + it.effect("degrades to unknown when no credential is on disk", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-droid-auth-" }); + + const auth = yield* detectDroidAuth({ HOME: home }); + + assert.deepEqual(auth, { status: "unknown" }); + }), + ); +}); + +it.layer(NodeServices.layer)("checkDroidProviderStatus", (it) => { + it.effect("issues the three inventory requests concurrently", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* makeInventoryProbeBinary("concurrent"); + const snapshot = yield* checkDroidProviderStatus( + decodeDroidSettings({ enabled: true, binaryPath }), + { FACTORY_API_KEY: "test-key", PATH: process.env.PATH }, + ); + + assert.equal(snapshot.status, "ready"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["concurrent-3"], + ); + assert.deepEqual(snapshot.slashCommands, [ + { name: "review", description: "Review changes" }, + ]); + assert.deepEqual(snapshot.skills, [ + { + name: "verify", + path: "/skills/verify/SKILL.md", + enabled: true, + scope: "personal", + }, + ]); + }), + ), + ); + + it.effect("points a user with no droid binary at the supported installer", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = "/definitely/not/installed/t3-droid"; + const snapshot = yield* checkDroidProviderStatus( + decodeDroidSettings({ enabled: true, binaryPath }), + { PATH: process.env.PATH }, + ); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal( + snapshot.message, + [ + `Droid CLI command \`${binaryPath}\` was not found.`, + `Install the Droid CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + "See https://docs.factory.ai/cli/getting-started/quickstart.", + ].join(" "), + ); + }), + ), + ); + + it.effect("warns and uses the complete fallback when one inventory request fails", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* makeInventoryProbeBinary("commands-error"); + const snapshot = yield* checkDroidProviderStatus( + decodeDroidSettings({ + enabled: true, + binaryPath, + customModels: ["custom:test-model"], + }), + { FACTORY_API_KEY: "test-key", PATH: process.env.PATH }, + ); + + assert.equal(snapshot.status, "warning"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["claude-opus-5", "claude-sonnet-5", "custom:test-model"], + ); + assert.deepEqual(snapshot.slashCommands, []); + assert.deepEqual(snapshot.skills, []); + assert.equal( + snapshot.message, + "Droid inventory discovery failed. Using fallback models; slash commands and skills are unavailable.", + ); + }), + ), + ); + + it.effect("warns and uses the complete fallback when inventory decoding fails", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* makeInventoryProbeBinary("malformed-model"); + const snapshot = yield* checkDroidProviderStatus( + decodeDroidSettings({ + enabled: true, + binaryPath, + customModels: ["custom:test-model"], + }), + { FACTORY_API_KEY: "test-key", PATH: process.env.PATH }, + ); + + assert.equal(snapshot.status, "warning"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["claude-opus-5", "claude-sonnet-5", "custom:test-model"], + ); + assert.deepEqual(snapshot.slashCommands, []); + assert.deepEqual(snapshot.skills, []); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts new file mode 100644 index 000000000000..aec3754ee71a --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -0,0 +1,491 @@ +import { + type DroidSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderAuth, + type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + type DroidCommandInfo, + DroidListCommandsResult, + DroidListModelsResult, + DroidListSkillsResult, + type DroidModelInfo, + type DroidSkillInfo, +} from "../droid/DroidProtocol.ts"; +import { makeDroidRpcClient } from "../droid/DroidRpcClient.ts"; +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const DROID_PRESENTATION = { + displayName: "Droid", + badgeLabel: "Early Access", + // Droid's Spec Mode maps onto the plan/build toggle, and models switch + // in-session via droid.update_session_settings. + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const INVENTORY_DISCOVERY_TIMEOUT_MS = 15_000; +const DROID_DEFAULT_MODEL = "claude-opus-5"; + +export const DROID_LOGIN_MESSAGE = "Run `droid` in a terminal to sign in to Factory."; + +const DROID_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: DROID_DEFAULT_MODEL, + name: "Claude Opus 5", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "claude-sonnet-5", + name: "Claude Sonnet 5", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function droidModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = DROID_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +function reasoningEffortCapabilities(model: DroidModelInfo): ModelCapabilities { + const efforts = model.supportedReasoningEfforts; + if (efforts.length === 0) return EMPTY_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning effort", + options: efforts.map((effort) => ({ + value: effort, + label: effort, + ...(model.defaultReasoningEffort === effort ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +/** + * Every model the CLI reports is a probe result, including the `custom:` entries a + * user configured in Droid's own settings. They stay `isCustom: false` because T3's + * flag means "slug the user typed into T3's custom-model field": custom rows render + * from that config list, so marking a probe model custom would drop it from the + * Models section entirely instead of merely labelling it. + */ +export function buildDroidDiscoveredModels( + models: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return models + .filter((model) => model.disabled !== true) + .map((model): ServerProviderModel | undefined => { + const slug = model.id.trim(); + if (!slug || seen.has(slug)) return undefined; + seen.add(slug); + return { + slug, + name: model.displayName.trim() || slug, + shortName: model.shortDisplayName.trim(), + isCustom: false, + ...(slug === DROID_DEFAULT_MODEL ? { isDefault: true } : {}), + capabilities: reasoningEffortCapabilities(model), + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +export function buildDroidSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const slashCommands: ServerProviderSlashCommand[] = []; + for (const command of commands) { + const name = command.name.trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + const description = command.description.trim(); + const hint = command.argumentHint?.trim(); + slashCommands.push({ + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return slashCommands.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Droid's own user-facing surfaces hide skills the user cannot invoke — built-ins + * are authored `userInvocable: false` (factory-mono skills/builtin/loadBuiltinSkill.ts) + * and filtered out of its command palette (acp/session/availableCommands.ts). We apply + * the same rule, but carry `enabled` through instead of filtering on it: the skill + * contract models disabled state, and clients render it. + */ +export function buildDroidSkills( + skills: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const providerSkills: ServerProviderSkill[] = []; + for (const skill of skills) { + if (skill.userInvocable === false) continue; + const name = skill.name.trim(); + const path = skill.filePath.trim(); + if (!name || !path || seen.has(name)) continue; + seen.add(name); + const description = skill.description?.trim(); + // SkillLocation is `project | personal | builtin | automation`; the first two + // are already understood by the client's skill-source resolver. + const scope = skill.location.trim(); + providerSkills.push({ + name, + path, + enabled: skill.enabled !== false, + scope, + ...(description ? { description, shortDescription: description } : {}), + }); + } + return providerSkills.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Detect the credentials `droid exec` would resolve: FACTORY_API_KEY always + * wins; otherwise the stored WorkOS login under the Factory home directory. + * macOS keychain-only logins have no on-disk artifact, so absence degrades + * to `unknown`, never a false `unauthenticated`. + */ +export const detectDroidAuth = Effect.fn("detectDroidAuth")(function* ( + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + if (environment.FACTORY_API_KEY?.trim()) { + return { status: "authenticated", type: "api-key", label: "API key" }; + } + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // FACTORY_HOME_OVERRIDE replaces the *user home*, not the .factory dir + // (factory-mono packages/environment/src/resolve.ts resolveHomeDir). + const home = + environment.FACTORY_HOME_OVERRIDE?.trim() || + environment.HOME?.trim() || + environment.USERPROFILE?.trim(); + const factoryHome = home ? path.join(home, ".factory") : undefined; + if (!factoryHome) { + return { status: "unknown" }; + } + for (const candidate of ["auth.v2.keyring", "auth.v2.file"]) { + const exists = yield* fileSystem + .exists(path.join(factoryHome, candidate)) + .pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return { status: "authenticated", type: "oauth", label: "Factory account" }; + } + } + return { status: "unknown" }; +}); + +/** + * One droid process answers every inventory question. Startup is the expensive part, + * and `list_models`/`list_commands`/`list_skills` are all session-less handlers + * (factory-mono streamingJsonRpcExecRunner.ts) resolved against this process's cwd, + * so environment-scoped commands and skills come back without initializing a session. + */ +const discoverDroidInventory = ( + droidSettings: DroidSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const rpc = yield* makeDroidRpcClient({ + command: droidSettings.binaryPath, + args: ["exec", "--input-format", "stream-jsonrpc", "--output-format", "stream-jsonrpc"], + cwd: process.cwd(), + env: environment, + }); + + const [modelResult, commandResult, skillResult] = yield* Effect.all( + [ + rpc.request("droid.list_models", {}).pipe(Effect.flatMap(decodeListModelsResult)), + rpc.request("droid.list_commands", {}).pipe(Effect.flatMap(decodeListCommandsResult)), + rpc.request("droid.list_skills", {}).pipe(Effect.flatMap(decodeListSkillsResult)), + ], + { concurrency: "unbounded" }, + ); + + return { + models: buildDroidDiscoveredModels(modelResult.models), + slashCommands: buildDroidSlashCommands(commandResult.commands), + skills: buildDroidSkills(skillResult.skills), + }; + }).pipe(Effect.scoped); + +const droidCliCommandMissingMessage = (droidSettings: DroidSettings) => { + const command = droidSettings.binaryPath || "droid"; + return [ + `Droid CLI command \`${command}\` was not found.`, + `Install the Droid CLI, make sure \`${command}\` is on PATH, then restart T3 Code.`, + "See https://docs.factory.ai/cli/getting-started/quickstart.", + ].join(" "); +}; + +const runDroidVersionCommand = ( + droidSettings: DroidSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = droidSettings.binaryPath || "droid"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export function buildInitialDroidProviderSnapshot( + droidSettings: DroidSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = droidModelsFromSettings(droidSettings.customModels); + + if (!droidSettings.enabled) { + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Droid is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Droid CLI availability...", + }, + }); + }); +} + +export const checkDroidProviderStatus = Effect.fn("checkDroidProviderStatus")(function* ( + droidSettings: DroidSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = droidModelsFromSettings(droidSettings.customModels); + + if (!droidSettings.enabled) { + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Droid is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runDroidVersionCommand(droidSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Droid CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: droidSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? droidCliCommandMissingMessage(droidSettings) + : "Failed to execute Droid CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: droidSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Droid CLI is installed but timed out while running `droid --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Droid CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: droidSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Droid CLI is installed but failed to run.", + }, + }); + } + + const auth = yield* detectDroidAuth(environment); + const discoveryExit = yield* discoverDroidInventory(droidSettings, environment).pipe( + Effect.timeoutOption(INVENTORY_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + const inventory = + Exit.isSuccess(discoveryExit) && Option.isSome(discoveryExit.value) + ? discoveryExit.value.value + : undefined; + let inventoryWarning: string | undefined; + if (inventory === undefined) { + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Droid inventory discovery failed.", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + inventoryWarning = + "Droid inventory discovery failed. Using fallback models; slash commands and skills are unavailable."; + } else { + yield* Effect.logWarning( + `Droid inventory discovery timed out after ${INVENTORY_DISCOVERY_TIMEOUT_MS}ms.`, + ); + inventoryWarning = `Droid inventory discovery timed out after ${INVENTORY_DISCOVERY_TIMEOUT_MS}ms. Using fallback models; slash commands and skills are unavailable.`; + } + } + const models = + inventory !== undefined && inventory.models.length > 0 + ? droidModelsFromSettings(droidSettings.customModels, inventory.models) + : fallbackModels; + let message = inventoryWarning; + if (auth.status === "unknown") { + message = message ? `${message} ${DROID_LOGIN_MESSAGE}` : DROID_LOGIN_MESSAGE; + } + + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: droidSettings.enabled, + checkedAt, + models, + ...(inventory ? { slashCommands: inventory.slashCommands, skills: inventory.skills } : {}), + probe: { + installed: true, + version, + status: inventoryWarning ? "warning" : "ready", + auth, + ...(message ? { message } : {}), + }, + }); +}); + +const decodeListModelsResult = Schema.decodeUnknownEffect(DroidListModelsResult); +const decodeListCommandsResult = Schema.decodeUnknownEffect(DroidListCommandsResult); +const decodeListSkillsResult = Schema.decodeUnknownEffect(DroidListSkillsResult); + +export const enrichDroidSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Droid version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..659e5697d5c9 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -28,6 +28,7 @@ import { type ClaudeSettings, type CodexSettings, type CursorSettings, + type DroidSettings, type GrokSettings, type OpenCodeSettings, ProviderDriverKind, @@ -46,6 +47,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { DroidDriver } from "../Drivers/DroidDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; @@ -133,6 +135,13 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeDroidConfig = (overrides: Partial): DroidSettings => ({ + enabled: false, + binaryPath: "droid", + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -321,12 +330,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const droidId = ProviderInstanceId.make("droid_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const droidDriverKind = ProviderDriverKind.make("droid"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -362,10 +373,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [droidId]: { + driver: droidDriverKind, + displayName: "Droid", + enabled: false, + config: makeDroidConfig({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver, DroidDriver], configMap, }); @@ -375,9 +392,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, droidId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -388,16 +405,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const droid = yield* registry.getInstance(droidId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(droid?.driverKind).toBe(droidDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(droid?.displayName).toBe("Droid"); // Every instance owns its own set of closures — no sharing across // drivers. `adapter` / `textGeneration` / `snapshot` are all @@ -410,6 +430,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.adapter, grok!.adapter, openCode!.adapter, + droid!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -418,6 +439,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + droid!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -426,6 +448,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + droid!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -468,6 +491,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(openCodeSnapshot.continuation?.groupKey).toBe( `${openCodeDriverKind}:instance:${openCodeId}`, ); + + const droidSnapshot = yield* droid!.snapshot.getSnapshot; + expect(droidSnapshot.instanceId).toBe(droidId); + expect(droidSnapshot.driver).toBe(droidDriverKind); + expect(droidSnapshot.enabled).toBe(false); + expect(droidSnapshot.continuation?.groupKey).toBe(`${droidDriverKind}:instance:${droidId}`); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..59fd32fdda71 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1381,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + droid: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals @@ -1493,6 +1494,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + droid: { enabled: false }, }, }), ), @@ -1607,6 +1609,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + droid: { enabled: false }, }, providerInstances: { ghost_main: { @@ -1744,6 +1747,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "droid", "grok", "opencode", ]); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..40eef2f17306 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -1190,6 +1189,35 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("persists the post-rollback snapshot even when the resume cursor is unchanged", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = asThreadId("thread-rollback-snapshot"); + const session = yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const beforeRollback = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(beforeRollback), true); + if (Option.isNone(beforeRollback)) { + return; + } + + yield* advanceTestClock(50); + yield* provider.rollbackConversation({ threadId, numTurns: 1 }); + + const afterRollback = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(afterRollback), true); + if (Option.isSome(afterRollback)) { + assert.notEqual(afterRollback.value.lastSeenAt, beforeRollback.value.lastSeenAt); + assert.deepEqual(afterRollback.value.resumeCursor, session.resumeCursor); + } + }), + ); + it.effect("preserves the persisted binding when stopping a session", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1762,6 +1790,92 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("skips completed-turn snapshot writes while the resume cursor is unchanged", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = asThreadId("thread-snapshot-unchanged"); + const session = yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const initialRuntime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(initialRuntime), true); + if (Option.isNone(initialRuntime)) { + return; + } + + fanout.codex.updateSession(threadId, (current) => ({ + ...current, + resumeCursor: + current.resumeCursor !== null && typeof current.resumeCursor === "object" + ? { ...current.resumeCursor } + : current.resumeCursor, + })); + for (const eventId of ["evt-snapshot-unchanged-1", "evt-snapshot-unchanged-2"]) { + fanout.codex.emit({ + type: "turn.completed", + eventId: asEventId(eventId), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId(`turn-${eventId}`), + status: "completed", + }); + yield* advanceTestClock(50); + } + + const unchangedRuntime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(unchangedRuntime), true); + if (Option.isNone(unchangedRuntime)) { + return; + } + assert.equal(unchangedRuntime.value.lastSeenAt, initialRuntime.value.lastSeenAt); + assert.deepEqual(unchangedRuntime.value.resumeCursor, session.resumeCursor); + }), + ); + + it.effect("persists a completed-turn snapshot when the resume cursor changes", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = asThreadId("thread-snapshot-changed"); + const session = yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const changedResumeCursor = { + opaque: `resume-successor-${String(threadId)}`, + }; + fanout.codex.updateSession(threadId, (current) => ({ + ...current, + resumeCursor: changedResumeCursor, + })); + fanout.codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-snapshot-changed"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId("turn-snapshot-changed"), + status: "completed", + }); + yield* advanceTestClock(50); + + const changedRuntime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(changedRuntime), true); + if (Option.isSome(changedRuntime)) { + assert.notDeepEqual(changedRuntime.value.resumeCursor, session.resumeCursor); + assert.deepEqual(changedRuntime.value.resumeCursor, changedResumeCursor); + } + }), + ); + it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b8cd0df539ac..0153e9569740 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -28,6 +28,7 @@ import { import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -312,6 +313,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + const persistedResumeCursors = yield* Ref.make( + new Map>(), + ); + const rememberPersistedResumeCursor = ( + providerInstanceId: ProviderInstanceId, + threadId: ThreadId, + resumeCursor: unknown | null, + ) => + Ref.update(persistedResumeCursors, (current) => { + const next = new Map(current); + const instanceCursors = new Map(next.get(providerInstanceId)); + instanceCursors.set(threadId, resumeCursor); + next.set(providerInstanceId, instanceCursors); + return next; + }); + const upsertSessionBinding = ( session: ProviderSession, threadId: ThreadId, @@ -335,8 +352,46 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), runtimePayload: toRuntimePayloadFromSession(session, extra), }); + yield* rememberPersistedResumeCursor( + providerInstanceId, + threadId, + session.resumeCursor ?? null, + ); }); + // Adapters can change their resume cursor mid-session (droid compaction and + // rewind mint successor sessions). Persist changed snapshots when a turn + // settles so the durable binding follows the live conversation without + // rewriting stable cursors for every provider. + const persistSessionSnapshot = ( + adapter: ProviderAdapterShape, + instanceId: ProviderInstanceId, + threadId: ThreadId, + force = false, + ) => + adapter.listSessions().pipe( + Effect.flatMap((activeSessions) => { + const live = activeSessions.find((session) => session.threadId === threadId); + if (!live) { + return Effect.void; + } + const liveResumeCursor = live.resumeCursor ?? null; + return Ref.get(persistedResumeCursors).pipe( + Effect.flatMap((persisted) => { + const persistedResumeCursor = persisted.get(instanceId)?.get(threadId); + return !force && + persistedResumeCursor !== undefined && + Equal.equals(persistedResumeCursor, liveResumeCursor) + ? Effect.void + : upsertSessionBinding({ ...live, providerInstanceId: instanceId }, threadId); + }), + ); + }), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.snapshot-persist-failed", { threadId, cause }), + ), + ); + const processRuntimeEvent = ( source: { readonly instanceId: ProviderInstanceId; @@ -394,6 +449,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: adapter.provider, }, event, + ).pipe( + Effect.andThen( + event.type === "turn.completed" + ? persistSessionSnapshot(adapter, id, event.threadId) + : Effect.void, + ), ), ).pipe(Effect.forkScoped); } @@ -799,6 +860,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( lastRuntimeEventAt: yield* nowIso, }, }); + if (turn.resumeCursor !== undefined) { + yield* rememberPersistedResumeCursor(routed.instanceId, input.threadId, turn.resumeCursor); + } yield* analytics.record("provider.turn.sent", { provider: routed.adapter.provider, model: input.modelSelection?.model, @@ -1103,6 +1167,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.rollback_turns": input.numTurns, }); yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); + // A rollback can re-anchor the provider on a forked session; persist the + // adapter's post-rollback snapshot so the resume cursor follows it. + yield* persistSessionSnapshot(routed.adapter, routed.instanceId, routed.threadId, true); yield* analytics.record("provider.conversation.rolled_back", { provider: routed.adapter.provider, turns: input.numTurns, diff --git a/apps/server/src/provider/Services/DroidAdapter.ts b/apps/server/src/provider/Services/DroidAdapter.ts new file mode 100644 index 000000000000..52974e77f935 --- /dev/null +++ b/apps/server/src/provider/Services/DroidAdapter.ts @@ -0,0 +1,16 @@ +/** + * DroidAdapter — shape type for the Factory Droid provider adapter. + * + * The driver model ({@link ../Drivers/DroidDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module DroidAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * DroidAdapterShape — per-instance Droid adapter contract. + */ +export interface DroidAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..031364acd684 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 { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -36,6 +37,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DroidDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray]> = [ + [ + "assistant_text_delta", + { type: "assistant_text_delta", messageId: "m1", blockIndex: 0, textDelta: "hello" }, + ], + ["assistant_text_complete", { type: "assistant_text_complete", messageId: "m1", blockIndex: 0 }], + [ + "thinking_text_delta", + { type: "thinking_text_delta", messageId: "m1", blockIndex: 0, textDelta: "hmm" }, + ], + [ + "thinking_text_complete", + { type: "thinking_text_complete", messageId: "m1", blockIndex: 0, durationMs: 12 }, + ], + [ + "tool_call", + { + type: "tool_call", + toolUse: { type: "tool_use", id: "tool-1", input: { path: "README.md" }, name: "Read" }, + }, + ], + [ + "tool_result", + { + type: "tool_result", + messageId: "m1", + toolUseId: "tool-1", + content: [{ type: "text", text: "contents" }], + }, + ], + [ + "tool_execution_phase_changed", + { + type: "tool_execution_phase_changed", + toolUseId: "tool-1", + toolName: "Read", + phase: "executing", + }, + ], + ["create_message", { type: "create_message", message: { id: "m1", role: "assistant" } }], + ["droid_working_state_changed", { type: "droid_working_state_changed", newState: "thinking" }], + [ + "agent_turn_completed", + { type: "agent_turn_completed", reason: "completed", turnId: "turn-1", tokenUsage: usage }, + ], + [ + "session_token_usage_changed", + { + type: "session_token_usage_changed", + sessionId: "s1", + tokenUsage: usage, + lastCallTokenUsage: { + inputTokens: 8, + cacheReadTokens: 2, + outputTokens: 4, + }, + }, + ], + [ + "session_compacted", + { + type: "session_compacted", + summaryId: "summary-1", + removedCount: 12, + visibleBoundaryMessageId: "message-4", + }, + ], + [ + "error", + { + type: "error", + message: "bad", + errorType: "SessionError", + timestamp: "2026-08-23T00:00:00.000Z", + }, + ], + ["llm_retry", { type: "llm_retry", attempt: 2, reason: "rate_limited" }], + [ + "session_title_updated", + { type: "session_title_updated", title: "A useful title", updateType: "llm_generated" }, + ], + [ + "child_session_available", + { type: "child_session_available", childSessionId: "child-1", timestamp: 123 }, + ], + [ + "permission_resolved", + { + type: "permission_resolved", + requestId: "permission-1", + toolUseIds: ["tool-1"], + selectedOption: "proceed_once", + }, + ], + [ + "queued_messages_discarded", + { type: "queued_messages_discarded", text: "discarded", requestId: "queued-1" }, + ], + ["mcp_status_changed", { type: "mcp_status_changed", servers: [], summary: { status: "ready" } }], + [ + "settings_updated", + { + type: "settings_updated", + requestId: "settings-1", + settings: { modelId: "mock-fast", reasoningEffort: "high", autonomyLevel: "medium" }, + }, + ], + [ + "structured_output", + { type: "structured_output", messageId: "m1", structuredOutput: { answer: 42 } }, + ], +]; + +describe("DroidSessionNotification", () => { + for (const [type, fixture] of fixtures) { + it(`decodes ${type}`, () => { + const decoded = decodeNotification(fixture); + assert.equal(decoded.type, type); + }); + } + + it("decodes unknown notification types into the forward-compatible fallback", () => { + const decoded = decodeNotification({ + type: "future_notification", + newField: { nested: true }, + }); + + assert.equal(decoded.type, "__unknown__"); + assert.deepStrictEqual(decoded, { type: "__unknown__" }); + }); + + it("decodes ignored notifications from their discriminator alone", () => { + assert.deepStrictEqual( + decodeNotification({ + type: "settings_updated", + settings: "reshaped-by-a-newer-cli", + }), + { type: "settings_updated" }, + ); + }); + + it("tolerates and strips extra fields from known notifications", () => { + const decoded = decodeNotification({ + type: "assistant_text_delta", + messageId: "m1", + blockIndex: 0, + textDelta: "hello", + addedByNewerCli: true, + }); + + assert.equal(decoded.type, "assistant_text_delta"); + assert.notProperty(decoded, "addedByNewerCli"); + }); + + it("decodes attributed tool progress and strips unknown update fields", () => { + const decoded = decodeNotification({ + type: "tool_progress_update", + toolUseId: "tool-1", + toolName: "Task", + update: { + type: "status", + status: "running", + text: "Inspecting the repository", + subagentSessionId: "child-1", + addedByNewerCli: true, + }, + }); + + assert.equal(decoded.type, "tool_progress_update"); + if (decoded.type === "tool_progress_update") { + assert.deepStrictEqual(decoded.update, { + status: "running", + text: "Inspecting the repository", + subagentSessionId: "child-1", + }); + assert.notProperty(decoded.update, "addedByNewerCli"); + } + }); + + it("decodes tool progress without a subagent session id", () => { + const decoded = decodeNotification({ + type: "tool_progress_update", + toolUseId: "tool-2", + toolName: "Execute", + update: { + type: "message", + details: "Still running", + valueSnippet: "line 42", + }, + }); + + assert.equal(decoded.type, "tool_progress_update"); + if (decoded.type === "tool_progress_update") { + assert.deepStrictEqual(decoded.update, { + details: "Still running", + valueSnippet: "line 42", + }); + assert.notProperty(decoded.update, "subagentSessionId"); + } + }); + + it("decodes lastCallTokenUsage for context-window accounting", () => { + const decoded = decodeNotification({ + type: "session_token_usage_changed", + sessionId: "s1", + tokenUsage: usage, + lastCallTokenUsage: { + inputTokens: 8, + cacheReadTokens: 2, + outputTokens: 4, + }, + }); + + assert.equal(decoded.type, "session_token_usage_changed"); + if (decoded.type === "session_token_usage_changed") { + assert.deepStrictEqual(decoded.lastCallTokenUsage, { + inputTokens: 8, + cacheReadTokens: 2, + outputTokens: 4, + }); + } + }); + + it("rejects malformed lastCallTokenUsage instead of treating it as unknown", () => { + assert.throws(() => + decodeNotification({ + type: "session_token_usage_changed", + sessionId: "s1", + tokenUsage: usage, + lastCallTokenUsage: { + inputTokens: "invalid", + cacheReadTokens: 2, + }, + }), + ); + }); + + it("decodes spec_handoff as a successful terminal reason", () => { + const decoded = decodeNotification({ + type: "agent_turn_completed", + reason: "spec_handoff", + turnId: "spec-turn", + tokenUsage: usage, + }); + + assert.equal(decoded.type, "agent_turn_completed"); + if (decoded.type === "agent_turn_completed") { + assert.equal(decoded.reason, "spec_handoff"); + } + }); + + it("rejects completion reasons outside the protocol enum", () => { + assert.throws(() => + decodeNotification({ + type: "agent_turn_completed", + reason: "future_reason", + turnId: "future-turn", + tokenUsage: usage, + }), + ); + }); +}); + +describe("DroidInitializeSessionResult", () => { + it("decodes only the session id consumed by the adapter", () => { + assert.deepStrictEqual( + decodeInitializeSessionResult({ + sessionId: "session-1", + session: "reshaped-by-a-newer-cli", + settings: null, + }), + { sessionId: "session-1" }, + ); + }); +}); + +describe("DroidLoadSessionResult", () => { + it("decodes only resumed-session usage consumed by the adapter", () => { + const decoded = decodeLoadSessionResult({ + session: "reshaped-by-a-newer-cli", + settings: null, + lastCallTokenUsage: { + inputTokens: 21, + cacheReadTokens: 5, + outputTokens: 3, + }, + }); + + assert.deepStrictEqual(decoded.lastCallTokenUsage, { + inputTokens: 21, + cacheReadTokens: 5, + outputTokens: 3, + }); + }); +}); + +describe("DroidExecuteRewindResult", () => { + it("decodes only the successor session id consumed by the adapter", () => { + assert.deepStrictEqual( + decodeExecuteRewindResult({ + newSessionId: "session-rewound", + restoredCount: "reshaped-by-a-newer-cli", + }), + { newSessionId: "session-rewound" }, + ); + }); +}); + +describe("DroidPermissionRequest", () => { + it("decodes the canonical option and only permission detail fields the adapter consumes", () => { + const decoded = decodePermissionRequest({ + toolUses: [ + { + toolUse: { + type: "tool_use", + id: "tool-exec", + input: { command: "echo hello" }, + name: "Execute", + }, + confirmationType: "exec", + details: { + type: "exec", + fullCommand: "echo hello", + command: "echo", + extractedCommands: "reshaped-by-a-newer-cli", + impactLevel: { future: true }, + }, + }, + ], + options: [{ label: "Allow once", value: "proceed_once" }], + associatedSessionIds: "reshaped-by-a-newer-cli", + }); + + assert.deepStrictEqual( + { + toolUses: decoded.toolUses, + options: decoded.options, + }, + { + toolUses: [ + { + toolUse: { + id: "tool-exec", + input: { command: "echo hello" }, + name: "Execute", + }, + confirmationType: "exec", + details: { + type: "exec", + fullCommand: "echo hello", + command: "echo", + }, + }, + ], + options: [{ label: "Allow once", outcome: "proceed_once" }], + }, + ); + }); +}); + +describe("Droid inventory entries", () => { + it("requires the model metadata guaranteed by list_models", () => { + assert.throws(() => + decodeModelInfo({ + id: "mock-fast", + displayName: "Mock Fast", + }), + ); + }); + + it("requires the skill location guaranteed by list_skills", () => { + assert.throws(() => + decodeSkillInfo({ + name: "verify", + filePath: "/skills/verify/SKILL.md", + }), + ); + }); +}); diff --git a/apps/server/src/provider/droid/DroidProtocol.ts b/apps/server/src/provider/droid/DroidProtocol.ts new file mode 100644 index 000000000000..213814daaab3 --- /dev/null +++ b/apps/server/src/provider/droid/DroidProtocol.ts @@ -0,0 +1,448 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +// factory-mono: protocol/session/settings/schema.ts +export const DroidTokenUsage = Schema.Struct({ + inputTokens: Schema.Number, + outputTokens: Schema.Number, + cacheCreationTokens: Schema.Number, + cacheReadTokens: Schema.Number, + thinkingTokens: Schema.Number, + factoryCredits: Schema.optional(Schema.Number), +}); +export type DroidTokenUsage = typeof DroidTokenUsage.Type; + +export const DroidLastCallTokenUsage = Schema.Struct({ + inputTokens: Schema.Number, + cacheReadTokens: Schema.Number, + outputTokens: Schema.optional(Schema.Number), +}); +export type DroidLastCallTokenUsage = typeof DroidLastCallTokenUsage.Type; + +// factory-mono: protocol/llm/enums.ts +export const DroidReasoningEffort = Schema.Literals([ + "none", + "dynamic", + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); +export type DroidReasoningEffort = typeof DroidReasoningEffort.Type; + +// factory-mono: protocol/models/schemas.ts +export const DroidModelInfo = Schema.Struct({ + id: Schema.String, + displayName: Schema.String, + shortDisplayName: Schema.String, + modelProvider: Schema.String, + supportedReasoningEfforts: Schema.Array(DroidReasoningEffort), + defaultReasoningEffort: DroidReasoningEffort, + disabled: Schema.optional(Schema.Boolean), +}); +export type DroidModelInfo = typeof DroidModelInfo.Type; + +// factory-mono: protocol/droid/schemas/client.ts +export const DroidInitializeSessionResult = Schema.Struct({ + sessionId: Schema.String, +}); +export type DroidInitializeSessionResult = typeof DroidInitializeSessionResult.Type; + +export const DroidLoadSessionResult = Schema.Struct({ + tokenUsage: Schema.optional(DroidTokenUsage), + inclusiveTokenUsage: Schema.optional(DroidTokenUsage), + lastCallTokenUsage: Schema.optional(DroidLastCallTokenUsage), +}); +export type DroidLoadSessionResult = typeof DroidLoadSessionResult.Type; + +export const DroidExecuteRewindResult = Schema.Struct({ + newSessionId: Schema.String, +}); +export type DroidExecuteRewindResult = typeof DroidExecuteRewindResult.Type; + +export const DroidCommandInfo = Schema.Struct({ + name: Schema.String, + description: Schema.String, + argumentHint: Schema.optional(Schema.String), +}); +export type DroidCommandInfo = typeof DroidCommandInfo.Type; + +const DroidSkillLocation = Schema.Literals(["project", "personal", "builtin", "automation"]); + +export const DroidSkillInfo = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + location: DroidSkillLocation, + filePath: Schema.String, + enabled: Schema.optional(Schema.Boolean), + userInvocable: Schema.optional(Schema.Boolean), +}); +export type DroidSkillInfo = typeof DroidSkillInfo.Type; + +export const DroidListModelsResult = Schema.Struct({ + models: Schema.Array(DroidModelInfo), +}); + +export const DroidListCommandsResult = Schema.Struct({ + commands: Schema.Array(DroidCommandInfo), +}); + +export const DroidListSkillsResult = Schema.Struct({ + skills: Schema.Array(DroidSkillInfo), +}); + +// factory-mono: protocol/droid/schemas/cli.ts +export const DroidToolUse = Schema.Struct({ + id: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + name: Schema.String, +}); +export type DroidToolUse = typeof DroidToolUse.Type; + +const DroidAskUserQuestion = Schema.Struct({ + index: Schema.Number, + topic: Schema.String, + question: Schema.String, + options: Schema.Array(Schema.String), + multiSelect: Schema.optional(Schema.Boolean), +}); + +const DroidEditToolConfirmationDetails = Schema.Struct({ + type: Schema.Literal("edit"), + filePath: Schema.String, +}); + +const DroidExecuteToolConfirmationDetails = Schema.Struct({ + type: Schema.Literal("exec"), + fullCommand: Schema.String, + command: Schema.String, +}); + +const DroidCreateToolConfirmationDetails = Schema.Struct({ + type: Schema.Literal("create"), + filePath: Schema.String, +}); + +const DroidAskUserConfirmationDetails = Schema.Struct({ + type: Schema.Literal("ask_user"), + questionnaire: Schema.String, +}); + +const DroidExitSpecModeConfirmationDetails = Schema.Struct({ + type: Schema.Literal("exit_spec_mode"), + plan: Schema.String, + title: Schema.optional(Schema.String), +}); + +const DroidProposeMissionConfirmationDetails = Schema.Struct({ + type: Schema.Literal("propose_mission"), + proposal: Schema.String, + title: Schema.optional(Schema.String), +}); + +const DroidStartMissionRunConfirmationDetails = Schema.Struct({ + type: Schema.Literal("start_mission_run"), + runningMissionCount: Schema.Number, +}); + +const DroidApplyPatchToolConfirmationDetails = Schema.Struct({ + type: Schema.Literal("apply_patch"), + filePath: Schema.String, + files: Schema.optional( + Schema.Array( + Schema.Struct({ + filePath: Schema.String, + }), + ), + ), +}); + +const DroidMcpToolConfirmationDetails = Schema.Struct({ + type: Schema.Literal("mcp_tool"), + toolName: Schema.String, + serverName: Schema.optional(Schema.String), + actualToolName: Schema.optional(Schema.String), +}); + +const DroidSandboxViolationConfirmationDetails = Schema.Struct({ + type: Schema.Literal("sandbox_violation"), + violatingToolName: Schema.String, + target: Schema.String, + operationType: Schema.Literals(["read", "write", "network", "tool"]), + reason: Schema.String, +}); + +const DroidShieldViolationConfirmationDetails = Schema.Struct({ + type: Schema.Literal("droid_shield_violation"), + command: Schema.String, + reason: Schema.String, +}); + +export const DroidToolConfirmationDetails = Schema.Union([ + DroidEditToolConfirmationDetails, + DroidExecuteToolConfirmationDetails, + DroidCreateToolConfirmationDetails, + DroidAskUserConfirmationDetails, + DroidExitSpecModeConfirmationDetails, + DroidProposeMissionConfirmationDetails, + DroidStartMissionRunConfirmationDetails, + DroidApplyPatchToolConfirmationDetails, + DroidMcpToolConfirmationDetails, + DroidSandboxViolationConfirmationDetails, + DroidShieldViolationConfirmationDetails, +]); +export type DroidToolConfirmationDetails = typeof DroidToolConfirmationDetails.Type; + +export const DroidPermissionOption = Schema.Struct({ + label: Schema.String, + outcome: Schema.String, +}).pipe( + Schema.encodeKeys({ + outcome: "value", + }), +); +export type DroidPermissionOption = typeof DroidPermissionOption.Type; + +const DroidPermissionRequestFields = { + toolUses: Schema.Array( + Schema.Struct({ + toolUse: DroidToolUse, + confirmationType: Schema.Literals([ + "edit", + "exec", + "create", + "ask_user", + "exit_spec_mode", + "propose_mission", + "start_mission_run", + "apply_patch", + "mcp_tool", + "sandbox_violation", + "droid_shield_violation", + ]), + details: DroidToolConfirmationDetails, + }), + ), + options: Schema.Array(DroidPermissionOption), +} as const; + +const DroidPermissionRequestDecoded = Schema.Struct({ + ...DroidPermissionRequestFields, + raw: Schema.Record(Schema.String, Schema.Unknown), +}); + +const DroidPermissionRequestRaw = Schema.Record(Schema.String, Schema.Unknown); + +export const DroidPermissionRequest = DroidPermissionRequestRaw.pipe( + Schema.decodeTo( + DroidPermissionRequestDecoded, + SchemaTransformation.transformOrFail({ + decode: (raw) => + Effect.succeed({ + ...raw, + raw, + } as typeof DroidPermissionRequestDecoded.Encoded), + encode: ({ raw: _raw, ...request }) => + Effect.succeed(request as typeof DroidPermissionRequestRaw.Encoded), + }), + ), +); +export type DroidPermissionRequest = typeof DroidPermissionRequest.Type; + +export const DroidAskUserRequest = Schema.Struct({ + toolCallId: Schema.String, + questions: Schema.Array(DroidAskUserQuestion), +}); +export type DroidAskUserRequest = typeof DroidAskUserRequest.Type; + +const AssistantTextDelta = Schema.Struct({ + type: Schema.Literal("assistant_text_delta"), + messageId: Schema.String, + textDelta: Schema.String, +}); + +const AssistantTextComplete = Schema.Struct({ + type: Schema.Literal("assistant_text_complete"), + messageId: Schema.String, +}); + +const ThinkingTextDelta = Schema.Struct({ + type: Schema.Literal("thinking_text_delta"), + messageId: Schema.String, + textDelta: Schema.String, +}); + +const ThinkingTextComplete = Schema.Struct({ + type: Schema.Literal("thinking_text_complete"), + messageId: Schema.String, +}); + +const ToolCall = Schema.Struct({ + type: Schema.Literal("tool_call"), + toolUse: DroidToolUse, +}); + +const ToolResult = Schema.Struct({ + type: Schema.Literal("tool_result"), + toolUseId: Schema.String, + isError: Schema.optional(Schema.Boolean), +}); + +const ToolProgressUpdate = Schema.Struct({ + type: Schema.Literal("tool_progress_update"), + toolUseId: Schema.String, + toolName: Schema.String, + update: Schema.Struct({ + status: Schema.optional(Schema.String), + details: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + valueSnippet: Schema.optional(Schema.String), + subagentSessionId: Schema.optional(Schema.String), + }), +}); + +const CreateMessage = Schema.Struct({ + type: Schema.Literal("create_message"), + message: Schema.Unknown, +}); + +export const DroidAgentTurnCompleted = Schema.Struct({ + type: Schema.Literal("agent_turn_completed"), + reason: Schema.Literals([ + "completed", + "cancelled", + "permission_rejected", + "error", + "process_exit", + "spec_handoff", + "structured_output_missing", + "structured_output_invalid", + "structured_output_schema_invalid", + "model_usage_exhausted", + "model_authentication_failed", + "model_request_rejected", + "model_provider_unreachable", + "model_provider_unavailable", + "prompt_rejected", + "completion_persistence_failed", + "no_approver_available", + ]), + turnId: Schema.optional(Schema.String), + tokenUsage: DroidTokenUsage, + cumulativeTokenUsage: Schema.optional(DroidTokenUsage), +}); +export type DroidAgentTurnCompleted = typeof DroidAgentTurnCompleted.Type; + +const SessionTokenUsageChanged = Schema.Struct({ + type: Schema.Literal("session_token_usage_changed"), + tokenUsage: DroidTokenUsage, + inclusiveTokenUsage: Schema.optional(DroidTokenUsage), + lastCallTokenUsage: Schema.optional(DroidLastCallTokenUsage), +}); + +const ErrorNotification = Schema.Struct({ + type: Schema.Literal("error"), + message: Schema.String, +}); + +const LlmRetry = Schema.Struct({ + type: Schema.Literal("llm_retry"), + attempt: Schema.Number, +}); + +const SessionTitleUpdated = Schema.Struct({ + type: Schema.Literal("session_title_updated"), + title: Schema.String, +}); + +const ChildSessionAvailable = Schema.Struct({ + type: Schema.Literal("child_session_available"), + childSessionId: Schema.String, + subagentType: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), +}); + +const KnownDroidSessionNotification = Schema.Union([ + AssistantTextDelta, + AssistantTextComplete, + ThinkingTextDelta, + ThinkingTextComplete, + ToolCall, + ToolResult, + ToolProgressUpdate, + CreateMessage, + DroidAgentTurnCompleted, + SessionTokenUsageChanged, + ErrorNotification, + LlmRetry, + SessionTitleUpdated, + ChildSessionAvailable, + Schema.Struct({ type: Schema.Literal("tool_execution_phase_changed") }), + Schema.Struct({ type: Schema.Literal("droid_working_state_changed") }), + Schema.Struct({ type: Schema.Literal("session_compacted") }), + Schema.Struct({ type: Schema.Literal("permission_resolved") }), + Schema.Struct({ type: Schema.Literal("queued_messages_discarded") }), + Schema.Struct({ type: Schema.Literal("mcp_status_changed") }), + Schema.Struct({ type: Schema.Literal("settings_updated") }), + Schema.Struct({ type: Schema.Literal("structured_output") }), +]); + +const knownNotificationTypes = new Set([ + "assistant_text_delta", + "assistant_text_complete", + "thinking_text_delta", + "thinking_text_complete", + "tool_call", + "tool_result", + "tool_progress_update", + "tool_execution_phase_changed", + "create_message", + "droid_working_state_changed", + "agent_turn_completed", + "session_token_usage_changed", + "session_compacted", + "error", + "llm_retry", + "session_title_updated", + "child_session_available", + "permission_resolved", + "queued_messages_discarded", + "mcp_status_changed", + "settings_updated", + "structured_output", +]); + +const DroidUnknownNotificationPayload = Schema.Record(Schema.String, Schema.Unknown).check( + Schema.makeFilter( + (input) => + (typeof input.type === "string" && !knownNotificationTypes.has(input.type)) || + "Expected an unknown Droid notification type", + ), +); + +export const DroidUnknownNotification = DroidUnknownNotificationPayload.pipe( + Schema.decodeTo( + Schema.Struct({ + type: Schema.Literal("__unknown__"), + }), + SchemaTransformation.transformOrFail({ + decode: () => Effect.succeed({ type: "__unknown__" as const }), + encode: () => + Effect.succeed({ + type: "__unknown__", + } as typeof DroidUnknownNotificationPayload.Encoded), + }), + ), +); +export type DroidUnknownNotification = typeof DroidUnknownNotification.Type; + +export const DroidSessionNotification = Schema.Union([ + KnownDroidSessionNotification, + DroidUnknownNotification, +]); +export type DroidSessionNotification = typeof DroidSessionNotification.Type; diff --git a/apps/server/src/provider/droid/DroidRpcClient.test.ts b/apps/server/src/provider/droid/DroidRpcClient.test.ts new file mode 100644 index 000000000000..8ee8e8126c9e --- /dev/null +++ b/apps/server/src/provider/droid/DroidRpcClient.test.ts @@ -0,0 +1,567 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { DroidRpcError, DroidRpcSpawnError, makeDroidRpcClient } from "./DroidRpcClient.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/droid-mock-agent.ts"); + +interface CapturedLog { + readonly message: ReadonlyArray; +} + +const withCapturedLogs = (logs: CapturedLog[], effect: Effect.Effect) => { + const logger = Logger.make(({ message }) => { + logs.push({ + message: Array.isArray(message) ? message : [message], + }); + }); + return effect.pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); +}; + +const within = (effect: Effect.Effect, message: string | (() => string)) => + effect.pipe( + Effect.timeoutOption("5 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => Effect.die(new Error(typeof message === "string" ? message : message())), + onSome: Effect.succeed, + }), + ), + ); + +describe("DroidRpcClient helpers", () => { + it("derives transport messages from structured error attributes", () => { + const spawnError = new DroidRpcSpawnError({ + command: "droid", + cause: new Error("ENOENT"), + }); + const timeoutError = new DroidRpcError({ + kind: "timeout", + method: "droid.list_models", + requestId: "7", + timeoutMs: 25, + }); + + assert.equal(spawnError.message, "Failed to spawn Droid process for command: droid"); + assert.equal(timeoutError.message, "Droid request droid.list_models timed out after 25ms"); + }); +}); + +it.effect("handles a final response without a trailing newline", () => + Effect.gen(function* () { + const script = ` + let pending = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + pending += chunk; + const line = pending.split("\\n")[0]; + if (!line) return; + const request = JSON.parse(line); + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + type: "response", + id: request.id, + result: { complete: true } + }), () => process.exit(0)); + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const result = yield* client.request("droid.list_models", {}, { timeoutMs: 5_000 }); + assert.deepStrictEqual(result, { complete: true }); + + const exit = yield* within(client.exits, "process exit was not detected"); + assert.equal(exit.code, 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("handles responses delimited by a bare carriage return", () => + Effect.gen(function* () { + const script = ` + let pending = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + pending += chunk; + const line = pending.split("\\n")[0]; + if (!line) return; + const request = JSON.parse(line); + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + type: "response", + id: request.id, + result: { complete: true } + }) + "\\r" + JSON.stringify({ + jsonrpc: "2.0", + type: "notification", + method: "droid.session_notification", + params: { + notification: { + type: "diagnostic_sentinel" + } + } + }) + "\\n"); + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const result = yield* client.request("droid.list_models", {}, { timeoutMs: 500 }); + assert.deepStrictEqual(result, { complete: true }); + + yield* within(client.shutdown, "client shutdown did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("rejects JSON-RPC envelopes without a supported type discriminator", () => { + const logs: CapturedLog[] = []; + return withCapturedLogs( + logs, + Effect.gen(function* () { + const script = ` + process.stdin.setEncoding("utf8"); + process.stdin.once("data", (chunk) => { + const request = JSON.parse(chunk.split("\\n")[0]); + const write = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); + const response = { + jsonrpc: "2.0", + id: request.id, + result: { accepted: false } + }; + write(response); + write({ ...response, type: "event" }); + write({ ...response, type: "response", result: { accepted: true } }); + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const result = yield* client.request("droid.list_models", {}, { timeoutMs: 500 }); + assert.deepStrictEqual(result, { accepted: true }); + assert.deepStrictEqual( + logs + .map((log) => log.message[0]) + .filter((message) => String(message).includes("valid type discriminator")), + [ + "Unable to parse Droid JSON-RPC line: JSON-RPC line must include a valid type discriminator", + "Unable to parse Droid JSON-RPC line: JSON-RPC line must include a valid type discriminator", + ], + ); + + yield* within(client.shutdown, "client shutdown did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); +}); + +it.effect("correlates RPCs, decodes notifications, handles server requests, and detects exit", () => + Effect.gen(function* () { + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: [mockAgentPath], + env: { + T3_DROID_MOCK_EMIT_TOOL_CALL: "1", + T3_DROID_MOCK_REQUEST_PERMISSION: "1", + T3_DROID_MOCK_ASK_USER: "1", + T3_DROID_MOCK_EMIT_UNKNOWN_NOTIFICATION: "1", + }, + }); + + const notifications: Array<{ readonly type: string }> = []; + const notificationSessionIds: Array = []; + const turnCompleted = yield* Deferred.make(); + const notificationFiber = yield* Stream.runForEach(client.notifications, (envelope) => + Effect.sync(() => { + notifications.push(envelope.notification); + notificationSessionIds.push(envelope.sessionId); + }).pipe( + Effect.andThen( + envelope.notification.type === "agent_turn_completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + const initialized = (yield* client.request( + "droid.initialize_session", + { + machineId: "t3-test", + cwd: process.cwd(), + }, + { timeoutMs: 5_000 }, + )) as { readonly sessionId?: unknown }; + assert.equal(initialized.sessionId, "mock-session-1"); + + const [modelResult, commandResult, skillResult] = yield* Effect.all( + [ + client.request("droid.list_models", {}), + client.request("droid.list_commands", {}), + client.request("droid.list_skills", {}), + ], + { concurrency: "unbounded" }, + ); + assert.lengthOf((modelResult as { models: unknown[] }).models, 2); + assert.lengthOf((commandResult as { commands: unknown[] }).commands, 1); + assert.lengthOf((skillResult as { skills: unknown[] }).skills, 1); + + yield* client.request( + "droid.add_user_message", + { + messageId: "turn-1", + text: "hello", + }, + { timeoutMs: undefined }, + ); + + const permission = yield* within( + Stream.runHead(client.serverRequests), + () => + `permission request did not arrive; notifications=${notifications.map((notification) => notification.type).join(",")}`, + ); + assert.isTrue(Option.isSome(permission)); + if (Option.isNone(permission)) { + return; + } + assert.equal(permission.value.method, "droid.request_permission"); + assert.equal(permission.value.sessionId, undefined); + if (permission.value.method === "droid.request_permission") { + assert.equal(permission.value.params.options[0]?.outcome, "proceed_once"); + // The parsed view is projected down to the fields the adapter summarises, so `raw` is + // what lets it forward droid's original request as the approval event's args. + const rawToolUse = ( + permission.value.params.raw as { + toolUses: readonly { + toolUse: { type?: unknown }; + details: { impactLevel?: unknown; riskLevelReason?: unknown }; + }[]; + } + ).toolUses[0]; + assert.equal(rawToolUse?.toolUse.type, "tool_use"); + assert.equal(rawToolUse?.details.impactLevel, "low"); + assert.equal(rawToolUse?.details.riskLevelReason, "The mock command only prints text."); + assert.deepStrictEqual( + Object.keys(permission.value.params.toolUses[0]?.toolUse ?? {}).sort(), + ["id", "input", "name"], + ); + } + yield* permission.value.respond({ selectedOption: "proceed_once" }); + + const ask = yield* within( + Stream.runHead(client.serverRequests), + () => + `ask-user request did not arrive; notifications=${notifications.map((notification) => notification.type).join(",")}`, + ); + assert.isTrue(Option.isSome(ask)); + if (Option.isNone(ask)) { + return; + } + assert.equal(ask.value.method, "droid.ask_user"); + assert.equal(ask.value.sessionId, undefined); + yield* ask.value.respond({ + answers: [{ index: 1, question: "Which scope?", answer: "workspace" }], + }); + + yield* within(Deferred.await(turnCompleted), "turn notification did not complete"); + + assert.includeMembers( + notifications.map((notification) => notification.type), + [ + "thinking_text_delta", + "tool_call", + "tool_result", + "assistant_text_delta", + "agent_turn_completed", + "__unknown__", + ], + ); + assert.isTrue(notificationSessionIds.every((sessionId) => sessionId === "mock-session-1")); + + yield* within(client.shutdown, "client shutdown did not complete"); + const exit = yield* within(client.exits, "process exit was not detected"); + assert.equal(exit.code, 0); + + yield* Fiber.interrupt(notificationFiber); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("preserves optional notification and server-request session envelopes", () => + Effect.gen(function* () { + const script = ` + const write = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); + write({ + jsonrpc: "2.0", + type: "notification", + method: "droid.session_notification", + params: { + notification: { + type: "assistant_text_delta", + messageId: "bare", + blockIndex: 0, + textDelta: "bare" + } + } + }); + write({ + jsonrpc: "2.0", + type: "request", + id: "permission-with-session", + method: "droid.request_permission", + params: { + sessionId: "permission-session", + toolUses: [{ + toolUse: { + type: "tool_use", + id: "tool-1", + input: { command: "echo hi" }, + name: "Execute" + }, + confirmationType: "exec", + details: { + type: "exec", + fullCommand: "echo hi", + command: "echo" + } + }], + options: [{ label: "Allow once", value: "proceed_once" }] + } + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const notification = yield* within( + Stream.runHead(client.notifications), + "bare notification did not arrive", + ); + assert.isTrue(Option.isSome(notification)); + if (Option.isSome(notification)) { + assert.equal(notification.value.sessionId, undefined); + assert.equal(notification.value.notification.type, "assistant_text_delta"); + } + + const request = yield* within( + Stream.runHead(client.serverRequests), + "server request did not arrive", + ); + assert.isTrue(Option.isSome(request)); + if (Option.isSome(request)) { + assert.equal(request.value.sessionId, "permission-session"); + assert.equal(request.value.method, "droid.request_permission"); + yield* request.value.respond({ selectedOption: "proceed_once" }); + } + + yield* within(client.shutdown, "client shutdown did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("fails registration after exit and ends every public stream", () => + Effect.gen(function* () { + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", "process.exit(7)"], + }); + + const exit = yield* within(client.exits, "process exit was not detected"); + assert.equal(exit.code, 7); + + const requestResult = yield* Effect.result( + client.request("droid.list_models", {}, { timeoutMs: undefined }), + ); + assert.equal(requestResult._tag, "Failure"); + if (requestResult._tag === "Failure") { + assert.instanceOf(requestResult.failure, DroidRpcError); + assert.equal(requestResult.failure.kind, "process-exit"); + assert.deepStrictEqual(requestResult.failure.data, exit); + } + + const [notifications, serverRequests] = yield* within( + Effect.all([ + Stream.runCollect(client.notifications), + Stream.runCollect(client.serverRequests), + ]), + "public streams did not end after process exit", + ); + assert.isEmpty(notifications); + assert.isEmpty(serverRequests); + yield* within(client.shutdown, "shutdown after exit did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("logs a response that arrives after its request timed out", () => { + const logs: CapturedLog[] = []; + return withCapturedLogs( + logs, + Effect.gen(function* () { + const script = ` + let pending = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + pending += chunk; + const line = pending.split("\\n")[0]; + if (!line) return; + const request = JSON.parse(line); + setTimeout(() => { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + type: "response", + id: request.id, + result: { late: true } + }) + "\\n", () => process.exit(0)); + }, 40); + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const requestResult = yield* Effect.result( + client.request("droid.list_models", {}, { timeoutMs: 10 }), + ); + assert.equal(requestResult._tag, "Failure"); + if (requestResult._tag === "Failure") { + assert.equal(requestResult.failure.kind, "timeout"); + } + + yield* within(client.exits, "late response was not processed before exit"); + assert.include( + logs.map((log) => String(log.message[0])), + "Droid request droid.list_models responded after timing out", + ); + + yield* within(client.shutdown, "client shutdown did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); +}); + +it.effect("bounds timed-out request retention while still logging a recent late response", () => { + const logs: CapturedLog[] = []; + return withCapturedLogs( + logs, + Effect.gen(function* () { + const requestCount = 257; + const script = ` + const requests = []; + let pending = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + pending += chunk; + const lines = pending.split("\\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (line) requests.push(JSON.parse(line)); + } + if (requests.length !== ${requestCount}) return; + setTimeout(() => { + const output = [requests[0], requests[requests.length - 1]].map((request) => + JSON.stringify({ + jsonrpc: "2.0", + type: "response", + id: request.id, + result: { late: true } + }) + ).join("\\n") + "\\n"; + process.stdout.write(output, () => process.exit(0)); + }, 250); + }); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + const results = yield* Effect.all( + Array.from({ length: requestCount }, () => + Effect.result(client.request("droid.list_models", {}, { timeoutMs: 100 })), + ), + { concurrency: "unbounded" }, + ); + assert.isTrue( + results.every((result) => result._tag === "Failure" && result.failure.kind === "timeout"), + ); + + yield* within(client.exits, "late responses were not processed before exit"); + assert.includeMembers( + logs.map((log) => String(log.message[0])), + [ + "Ignoring response for unknown Droid request 1", + `Droid request droid.list_models responded after timing out`, + ], + ); + + yield* within(client.shutdown, "client shutdown did not complete"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); +}); + +it.effect("logs bounded stdout and stderr diagnostics", () => { + const logs: CapturedLog[] = []; + return withCapturedLogs( + logs, + Effect.gen(function* () { + const script = ` + process.stdout.write("x".repeat(2500) + "\\n"); + process.stderr.write("y".repeat(2500)); + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + type: "notification", + method: "droid.session_notification", + params: { + notification: { + type: "diagnostic_sentinel" + } + } + }) + "\\n"); + process.stdin.resume(); + `; + const client = yield* makeDroidRpcClient({ + command: process.execPath, + args: ["-e", script], + }); + + yield* within(Stream.runHead(client.notifications), "sentinel notification did not arrive"); + yield* within(client.shutdown, "client shutdown did not complete"); + yield* within(client.exits, "process exit was not detected"); + + const parseLog = logs.find((log) => + String(log.message[0]).startsWith("Unable to parse Droid JSON-RPC line"), + ); + const parseDetails = parseLog?.message[1]; + assert.equal( + String( + typeof parseDetails === "object" && parseDetails !== null && "line" in parseDetails + ? parseDetails.line + : undefined, + ).length, + 2000, + ); + const stderrLog = logs.find((log) => String(log.message[0]).startsWith("Droid stderr:")); + assert.equal(String(stderrLog?.message[0]).length, 2000); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), + ); +}); diff --git a/apps/server/src/provider/droid/DroidRpcClient.ts b/apps/server/src/provider/droid/DroidRpcClient.ts new file mode 100644 index 000000000000..a58cf47243b9 --- /dev/null +++ b/apps/server/src/provider/droid/DroidRpcClient.ts @@ -0,0 +1,774 @@ +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + DroidAskUserRequest, + DroidPermissionRequest, + DroidSessionNotification, + type DroidAskUserRequest as DroidAskUserRequestType, + type DroidPermissionRequest as DroidPermissionRequestType, + type DroidSessionNotification as DroidSessionNotificationType, +} from "./DroidProtocol.ts"; + +const defaultRequestTimeoutMs = 30_000; +const gracefulShutdownTimeout = Duration.seconds(2); +const timedOutRequestRetentionLimit = 256; +const diagnosticTextLimit = 2000; + +export interface DroidRpcSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; +} + +export interface DroidProcessExit { + readonly code: number | null; + readonly signal?: string; + readonly description: string; +} + +export class DroidRpcSpawnError extends Schema.TaggedErrorClass()( + "DroidRpcSpawnError", + { + command: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Failed to spawn Droid process for command: ${this.command}`; + } +} + +export class DroidRpcError extends Schema.TaggedErrorClass()("DroidRpcError", { + kind: Schema.Literals([ + "encode", + "write", + "timeout", + "rpc", + "process-exit", + "duplicate-response", + ]), + method: Schema.optionalKey(Schema.String), + requestId: Schema.optionalKey(Schema.String), + code: Schema.optionalKey(Schema.Number), + data: Schema.optionalKey(Schema.Unknown), + cause: Schema.optionalKey(Schema.Defect()), + rpcMessage: Schema.optionalKey(Schema.String), + timeoutMs: Schema.optionalKey(Schema.Number), + exitDescription: Schema.optionalKey(Schema.String), +}) { + override get message() { + switch (this.kind) { + case "encode": + return "Failed to encode Droid JSON-RPC message"; + case "write": + return "Failed to write to Droid process stdin because it is closed"; + case "timeout": + return `Droid request ${this.method} timed out after ${this.timeoutMs}ms`; + case "rpc": + return this.rpcMessage ?? "Droid returned an invalid JSON-RPC error response"; + case "process-exit": + return this.requestId === undefined + ? `Cannot start Droid request ${this.method}: ${this.exitDescription}` + : `Droid process exited while ${this.method} was pending`; + case "duplicate-response": + return `Droid request ${this.method} responded after timing out`; + } + } +} + +interface DroidServerRequestBase { + readonly id: string; + readonly sessionId: string | undefined; + readonly respond: (result: unknown) => Effect.Effect; + readonly fail: (code: number, message: string) => Effect.Effect; +} + +export interface DroidPermissionServerRequest extends DroidServerRequestBase { + readonly method: "droid.request_permission"; + readonly params: DroidPermissionRequestType; +} + +export interface DroidAskUserServerRequest extends DroidServerRequestBase { + readonly method: "droid.ask_user"; + readonly params: DroidAskUserRequestType; +} + +export type DroidServerRequest = DroidPermissionServerRequest | DroidAskUserServerRequest; + +export interface DroidNotificationEnvelope { + readonly sessionId: string | undefined; + readonly notification: DroidSessionNotificationType; +} + +export interface DroidRpcClient { + readonly request: ( + method: string, + params: unknown, + options?: { readonly timeoutMs?: number | undefined }, + ) => Effect.Effect; + readonly notifications: Stream.Stream; + readonly serverRequests: Stream.Stream; + readonly exits: Effect.Effect; + readonly shutdown: Effect.Effect; +} + +interface ParsedJsonRpcMessage { + readonly jsonrpc: "2.0"; + readonly type: "request" | "response" | "notification"; + readonly id?: string | number | null; + readonly method?: string; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: unknown; + readonly [key: string]: unknown; +} + +type ParseJsonRpcLineResult = + | { readonly _tag: "Message"; readonly message: ParsedJsonRpcMessage } + | { readonly _tag: "Invalid"; readonly error: string }; + +function parseJsonRpcLine(line: string): ParseJsonRpcLineResult { + try { + const parsed: unknown = JSON.parse(line); + if (!Predicate.isObject(parsed) || Array.isArray(parsed)) { + return { _tag: "Invalid", error: "JSON-RPC line must contain an object" }; + } + if (parsed.jsonrpc !== "2.0") { + return { _tag: "Invalid", error: 'JSON-RPC line must include jsonrpc: "2.0"' }; + } + if (parsed.type !== "request" && parsed.type !== "response" && parsed.type !== "notification") { + return { + _tag: "Invalid", + error: "JSON-RPC line must include a valid type discriminator", + }; + } + return { + _tag: "Message", + message: { + ...parsed, + jsonrpc: "2.0", + type: parsed.type, + }, + }; + } catch (cause) { + return { + _tag: "Invalid", + error: cause instanceof Error ? cause.message : String(cause), + }; + } +} + +interface PendingRequest { + readonly _tag: "Pending"; + readonly method: string; + readonly deferred: Deferred.Deferred; +} + +interface TimedOutRequest { + readonly _tag: "TimedOut"; + readonly method: string; +} + +type RequestState = PendingRequest | TimedOutRequest; + +function markRequestTimedOut( + pending: ReadonlyMap, + requestId: string, + method: string, +): ReadonlyMap { + const next = new Map(pending); + next.delete(requestId); + next.set(requestId, { _tag: "TimedOut", method }); + + let timedOutCount = 0; + for (const request of next.values()) { + if (request._tag === "TimedOut") { + timedOutCount += 1; + } + } + if (timedOutCount <= timedOutRequestRetentionLimit) { + return next; + } + + for (const [retainedRequestId, request] of next) { + if (request._tag !== "TimedOut") { + continue; + } + next.delete(retainedRequestId); + timedOutCount -= 1; + if (timedOutCount <= timedOutRequestRetentionLimit) { + break; + } + } + return next; +} + +type DroidRpcLifecycle = + | { + readonly _tag: "Running"; + readonly pending: ReadonlyMap; + } + | { + readonly _tag: "ShuttingDown"; + readonly pending: ReadonlyMap; + readonly exit?: DroidProcessExit; + } + | { + readonly _tag: "Exited"; + readonly exit: DroidProcessExit; + }; + +const decodeNotification = Schema.decodeUnknownEffect(DroidSessionNotification); +const decodePermissionRequest = Schema.decodeUnknownEffect(DroidPermissionRequest); +const decodeAskUserRequest = Schema.decodeUnknownEffect(DroidAskUserRequest); +const encodeJsonRpcMessage = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +function jsonRpcErrorFromMessage(error: unknown, method: string, requestId: string): DroidRpcError { + if (Predicate.isObject(error) && typeof error.message === "string") { + return new DroidRpcError({ + kind: "rpc", + rpcMessage: error.message, + method, + requestId, + ...(typeof error.code === "number" ? { code: error.code } : {}), + ...("data" in error ? { data: error.data } : {}), + }); + } + return new DroidRpcError({ + kind: "rpc", + method, + requestId, + data: error, + }); +} + +export const makeDroidRpcClient = ( + input: DroidRpcSpawnInput, +): Effect.Effect< + DroidRpcClient, + DroidRpcSpawnError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const outgoing = yield* Queue.unbounded>(); + const notificationPubSub = yield* Queue.unbounded< + DroidNotificationEnvelope, + Cause.Done + >(); + const serverRequestPubSub = yield* Queue.unbounded>(); + const lifecycle = yield* SynchronizedRef.make({ + _tag: "Running", + pending: new Map(), + }); + const nextRequestId = yield* Ref.make(0); + const exitDeferred = yield* Deferred.make(); + + const publishDiagnostic = ( + message: string, + options?: { + readonly line?: string; + readonly cause?: unknown; + }, + ) => + Effect.logWarning(message.slice(0, diagnosticTextLimit), { + ...(options?.line === undefined + ? {} + : { line: options.line.slice(0, diagnosticTextLimit) }), + ...(options?.cause === undefined + ? {} + : { cause: String(options.cause).slice(0, diagnosticTextLimit) }), + }); + + const spawnCommand = yield* resolveSpawnCommand( + input.command, + input.args, + input.env ? { env: input.env, extendEnv: true } : {}, + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.env ? { env: input.env, extendEnv: true } : {}), + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.fromQueue(outgoing)), + endOnDone: true, + }, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new DroidRpcSpawnError({ + command: input.command, + cause, + }), + ), + ); + + const writeEnvelope = (message: Record): Effect.Effect => + encodeJsonRpcMessage(message).pipe( + Effect.map((encoded) => `${encoded}\n`), + Effect.mapError( + (cause) => + new DroidRpcError({ + kind: "encode", + cause, + }), + ), + Effect.flatMap((encoded) => Queue.offer(outgoing, encoded)), + Effect.flatMap((offered) => + offered + ? Effect.void + : Effect.fail( + new DroidRpcError({ + kind: "write", + }), + ), + ), + ); + + const sendResponse = ( + id: string, + result: + | { readonly _tag: "Success"; readonly value: unknown } + | { readonly _tag: "Failure"; readonly code: number; readonly message: string }, + ) => + writeEnvelope({ + jsonrpc: "2.0", + type: "response", + factoryApiVersion: "1.0.0", + id, + ...(result._tag === "Success" + ? { result: result.value } + : { error: { code: result.code, message: result.message } }), + }); + + const resolveResponse = (message: ParsedJsonRpcMessage): Effect.Effect => + Effect.gen(function* () { + if (message.id === undefined || message.id === null) { + yield* publishDiagnostic("Ignoring Droid JSON-RPC response without an id"); + return; + } + const requestId = String(message.id); + const requestState = yield* SynchronizedRef.modify(lifecycle, (state) => { + if (state._tag === "Exited") { + return [undefined, state] as const; + } + const found = state.pending.get(requestId); + if (!found) { + return [undefined, state] as const; + } + const next = new Map(state.pending); + next.delete(requestId); + return [found, { ...state, pending: next }] as const; + }); + if (!requestState) { + yield* publishDiagnostic(`Ignoring response for unknown Droid request ${requestId}`); + return; + } + if (requestState._tag === "TimedOut") { + const error = new DroidRpcError({ + kind: "duplicate-response", + method: requestState.method, + requestId, + }); + yield* publishDiagnostic(error.message, { cause: error }); + return; + } + if (message.error !== undefined) { + yield* Deferred.fail( + requestState.deferred, + jsonRpcErrorFromMessage(message.error, requestState.method, requestId), + ); + return; + } + yield* Deferred.succeed(requestState.deferred, message.result); + }); + + const publishServerRequest = ( + id: string, + sessionId: string | undefined, + method: "droid.request_permission" | "droid.ask_user", + params: DroidPermissionRequestType | DroidAskUserRequestType, + ) => { + const base = { + id, + sessionId, + respond: (result: unknown) => + sendResponse(id, { + _tag: "Success", + value: result, + }), + fail: (code: number, message: string) => + sendResponse(id, { + _tag: "Failure", + code, + message, + }), + }; + const request = + method === "droid.request_permission" + ? ({ + ...base, + method, + params: params as DroidPermissionRequestType, + } satisfies DroidPermissionServerRequest) + : ({ + ...base, + method, + params: params as DroidAskUserRequestType, + } satisfies DroidAskUserServerRequest); + return Queue.offer(serverRequestPubSub, request).pipe(Effect.asVoid); + }; + + const handleServerRequest = (message: ParsedJsonRpcMessage): Effect.Effect => + Effect.gen(function* () { + if (message.id === undefined || message.id === null || typeof message.method !== "string") { + yield* publishDiagnostic("Ignoring malformed server-initiated Droid request"); + return; + } + const id = String(message.id); + const sessionId = + Predicate.isObject(message.params) && typeof message.params.sessionId === "string" + ? message.params.sessionId + : undefined; + if (message.method === "droid.request_permission") { + const decoded = yield* decodePermissionRequest(message.params).pipe(Effect.result); + if (decoded._tag === "Failure") { + yield* publishDiagnostic("Unable to decode droid.request_permission params", { + cause: decoded.failure, + }); + yield* sendResponse(id, { + _tag: "Failure", + code: -32602, + message: "Invalid droid.request_permission params", + }).pipe(Effect.ignore); + return; + } + yield* publishServerRequest(id, sessionId, message.method, decoded.success); + return; + } + if (message.method === "droid.ask_user") { + const decoded = yield* decodeAskUserRequest(message.params).pipe(Effect.result); + if (decoded._tag === "Failure") { + yield* publishDiagnostic("Unable to decode droid.ask_user params", { + cause: decoded.failure, + }); + yield* sendResponse(id, { + _tag: "Failure", + code: -32602, + message: "Invalid droid.ask_user params", + }).pipe(Effect.ignore); + return; + } + yield* publishServerRequest(id, sessionId, message.method, decoded.success); + return; + } + yield* publishDiagnostic( + `Ignoring unsupported server-initiated Droid request ${message.method}`, + ); + yield* sendResponse(id, { + _tag: "Failure", + code: -32601, + message: `Unsupported Droid request: ${message.method}`, + }).pipe(Effect.ignore); + }); + + const handleNotification = (message: ParsedJsonRpcMessage): Effect.Effect => + Effect.gen(function* () { + if (message.method !== "droid.session_notification") { + return; + } + if (!Predicate.isObject(message.params)) { + yield* publishDiagnostic("Ignoring Droid session notification with invalid params"); + return; + } + const decoded = yield* decodeNotification(message.params.notification).pipe(Effect.result); + if (decoded._tag === "Failure") { + yield* publishDiagnostic("Unable to decode Droid session notification", { + cause: decoded.failure, + }); + return; + } + const sessionId = + typeof message.params.sessionId === "string" ? message.params.sessionId : undefined; + yield* Queue.offer(notificationPubSub, { + sessionId, + notification: decoded.success, + }); + }); + + const handleMessage = (message: ParsedJsonRpcMessage): Effect.Effect => { + switch (message.type) { + case "request": + return handleServerRequest(message); + case "notification": + return handleNotification(message); + case "response": + return resolveResponse(message); + } + }; + + const handleLine = (line: string) => { + const parsed = parseJsonRpcLine(line); + return parsed._tag === "Invalid" + ? publishDiagnostic(`Unable to parse Droid JSON-RPC line: ${parsed.error}`, { line }) + : handleMessage(parsed.message); + }; + + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim().length > 0), + Stream.runForEach(handleLine), + Effect.catch((cause) => publishDiagnostic("Droid stdout stream failed", { cause })), + Effect.forkIn(runtimeScope), + ); + + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((output) => + output.trim().length === 0 + ? Effect.void + : publishDiagnostic(`Droid stderr: ${output.trim()}`), + ), + Effect.catch(() => Effect.void), + Effect.forkIn(runtimeScope), + ); + + const processExitError = ( + exit: DroidProcessExit, + method: string, + requestId?: string, + ): DroidRpcError => + new DroidRpcError({ + kind: "process-exit", + method, + ...(requestId === undefined ? {} : { requestId }), + ...(requestId === undefined ? { exitDescription: exit.description } : {}), + data: exit, + }); + + const beginProcessExit = (exit: DroidProcessExit) => + SynchronizedRef.modify(lifecycle, (state) => { + if (state._tag === "Exited") { + return [false, state] as const; + } + return [ + true, + { + _tag: "ShuttingDown", + pending: state.pending, + exit, + }, + ] as const; + }).pipe( + Effect.flatMap((transitioned) => (transitioned ? Queue.end(outgoing) : Effect.void)), + Effect.asVoid, + ); + + const finishProcessExit = (exit: DroidProcessExit) => + SynchronizedRef.modify(lifecycle, (state) => { + if (state._tag === "Exited") { + return [undefined, state] as const; + } + const pending = Array.from(state.pending.entries()).filter( + (entry): entry is [string, PendingRequest] => entry[1]._tag === "Pending", + ); + return [pending, { _tag: "Exited", exit }] as const; + }).pipe( + Effect.flatMap((pending) => + pending === undefined + ? Effect.void + : Effect.forEach( + pending, + ([requestId, request]) => + Deferred.fail( + request.deferred, + processExitError(exit, request.method, requestId), + ), + { discard: true }, + ), + ), + ); + + yield* child.exitCode.pipe( + Effect.match({ + onFailure: (cause) => + ({ + code: null, + description: `Droid process exit status was unavailable: ${String(cause)}`, + }) satisfies DroidProcessExit, + onSuccess: (code) => + ({ + code: Number(code), + description: `Droid process exited with code ${Number(code)}`, + }) satisfies DroidProcessExit, + }), + Effect.flatMap((exit) => + Effect.gen(function* () { + yield* beginProcessExit(exit); + yield* Fiber.await(stdoutFiber); + yield* Fiber.await(stderrFiber); + yield* finishProcessExit(exit); + yield* Effect.all([Queue.end(notificationPubSub), Queue.end(serverRequestPubSub)], { + discard: true, + }); + yield* Deferred.succeed(exitDeferred, exit); + }), + ), + Effect.forkIn(runtimeScope), + ); + + const request: DroidRpcClient["request"] = (method, params, options) => + Effect.gen(function* () { + const requestId = String(yield* Ref.updateAndGet(nextRequestId, (id) => id + 1)); + const deferred = yield* Deferred.make(); + yield* SynchronizedRef.modifyEffect(lifecycle, (state) => { + if (state._tag === "Running") { + const next = new Map(state.pending); + next.set(requestId, { _tag: "Pending", method, deferred }); + return Effect.succeed([undefined, { ...state, pending: next }] as const); + } + const exit = + state._tag === "Exited" + ? state.exit + : (state.exit ?? + ({ + code: null, + description: "Droid process is shutting down", + } satisfies DroidProcessExit)); + return Effect.fail(processExitError(exit, method)); + }); + const timeoutMs = options === undefined ? defaultRequestTimeoutMs : options.timeoutMs; + const result = + timeoutMs === undefined + ? Deferred.await(deferred) + : Deferred.await(deferred).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap((result) => { + if (Option.isSome(result)) { + return Effect.succeed(result.value); + } + return SynchronizedRef.modify(lifecycle, (state) => { + if (state._tag === "Exited") { + return [false, state] as const; + } + const entry = state.pending.get(requestId); + if ( + entry === undefined || + entry._tag !== "Pending" || + entry.deferred !== deferred + ) { + return [false, state] as const; + } + return [ + true, + { + ...state, + pending: markRequestTimedOut(state.pending, requestId, method), + }, + ] as const; + }).pipe( + Effect.flatMap((markedTimedOut) => + markedTimedOut + ? Effect.fail( + new DroidRpcError({ + kind: "timeout", + method, + requestId, + timeoutMs, + }), + ) + : Deferred.await(deferred), + ), + ); + }), + ); + return yield* writeEnvelope({ + jsonrpc: "2.0", + type: "request", + factoryApiVersion: "1.0.0", + id: requestId, + method, + params, + }).pipe( + Effect.andThen(result), + Effect.ensuring( + SynchronizedRef.update(lifecycle, (state) => { + if (state._tag === "Exited") { + return state; + } + const entry = state.pending.get(requestId); + if (entry === undefined || entry._tag !== "Pending" || entry.deferred !== deferred) { + return state; + } + const next = new Map(state.pending); + next.delete(requestId); + return { ...state, pending: next }; + }), + ), + ); + }); + + const exits = Deferred.await(exitDeferred); + + const shutdown = SynchronizedRef.modifyEffect(lifecycle, (state) => { + if (state._tag !== "Running") { + return Effect.succeed([undefined, state] as const); + } + return Queue.end(outgoing).pipe( + Effect.as([ + undefined, + { + _tag: "ShuttingDown", + pending: state.pending, + }, + ] as const), + ); + }).pipe( + Effect.andThen( + Effect.raceFirst( + exits.pipe(Effect.as(true)), + Effect.sleep(gracefulShutdownTimeout).pipe(Effect.as(false)), + ), + ), + Effect.flatMap((exited) => + exited + ? Effect.void + : child + .kill({ killSignal: "SIGTERM", forceKillAfter: Duration.seconds(2) }) + .pipe(Effect.ignore), + ), + ); + + yield* Effect.addFinalizer(() => shutdown); + + return { + request, + notifications: Stream.fromQueue(notificationPubSub), + serverRequests: Stream.fromQueue(serverRequestPubSub), + exits, + shutdown, + } satisfies DroidRpcClient; + }); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 5683da2c1a82..6797ecda0fda 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -20,6 +20,7 @@ import { resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; +import { DroidProviderMaintenanceResolver } from "./Drivers/DroidDriver.ts"; const driver = (value: string) => ProviderDriverKind.make(value); const makeTempDir = (name: string) => @@ -597,4 +598,88 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { update: null, }); }); + + it.effect("routes curl-installed Droid binaries to droid's native self-update", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-droid-native-capabilities"); + const droidBinDir = NodePath.join(tempDir, ".local", "bin"); + NodeFS.mkdirSync(droidBinDir, { recursive: true }); + const droidPath = NodePath.join(droidBinDir, "droid"); + NodeFS.writeFileSync(droidPath, "#!/bin/sh\n"); + NodeFS.chmodSync(droidPath, 0o755); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + DroidProviderMaintenanceResolver, + { + binaryPath: "droid", + env: { + PATH: droidBinDir, + }, + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ + provider: driver("droid"), + packageName: "@factory/cli", + update: { + command: "droid update", + + executable: "droid", + + args: ["update"], + + lockKey: "droid-native", + }, + }); + }), + ); + + it("routes Windows-installed Droid binaries to droid's native self-update", () => { + expect( + DroidProviderMaintenanceResolver.resolve({ + binaryPath: "C:\\Users\\dev\\bin\\droid.exe", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("droid"), + packageName: "@factory/cli", + update: { + command: "droid update", + + executable: "droid", + + args: ["update"], + + lockKey: "droid-native", + }, + }); + }); + + it("keeps npm updates for Droid binaries inside npm's global node_modules tree", () => { + expect( + DroidProviderMaintenanceResolver.resolve({ + binaryPath: + "C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@factory\\cli\\bin\\droid.exe", + env: { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ).toEqual({ + provider: driver("droid"), + packageName: "@factory/cli", + update: { + command: "npm install -g --allow-scripts=@factory/cli @factory/cli@latest", + + executable: "npm", + + args: ["install", "-g", "--allow-scripts=@factory/cli", "@factory/cli@latest"], + + lockKey: "npm-global", + }, + }); + }); }); diff --git a/apps/server/src/textGeneration/DroidTextGeneration.test.ts b/apps/server/src/textGeneration/DroidTextGeneration.test.ts new file mode 100644 index 000000000000..8c611faf761a --- /dev/null +++ b/apps/server/src/textGeneration/DroidTextGeneration.test.ts @@ -0,0 +1,105 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { DroidSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeDroidTextGeneration } from "./DroidTextGeneration.ts"; + +const decodeDroidSettings = Schema.decodeSync(DroidSettings); + +function makeOversizedOutputDroid() { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-droid-text-")); + const scriptPath = NodePath.join(tempDir, "fake-droid.mjs"); + const binaryPath = NodePath.join(tempDir, "droid"); + NodeFS.writeFileSync( + scriptPath, + ` + import * as readline from "node:readline"; + + const write = (message) => process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + factoryApiVersion: "1.0.0", + ...message + }) + "\\n"); + const respond = (id, result) => write({ type: "response", id, result }); + const notify = (notification) => write({ + type: "notification", + method: "droid.session_notification", + params: { sessionId: "text-session", notification } + }); + + const lines = readline.createInterface({ input: process.stdin }); + for await (const line of lines) { + const request = JSON.parse(line); + if (request.method === "droid.initialize_session") { + respond(request.id, { sessionId: "text-session" }); + continue; + } + if (request.method === "droid.add_user_message") { + respond(request.id, {}); + notify({ + type: "assistant_text_delta", + messageId: "assistant-1", + blockIndex: 0, + textDelta: JSON.stringify({ title: "Bounded output" }) + }); + notify({ + type: "assistant_text_delta", + messageId: "assistant-1", + blockIndex: 0, + textDelta: " ".repeat(300_000) + }); + notify({ + type: "agent_turn_completed", + reason: "completed", + turnId: "turn-1", + tokenUsage: { + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + thinkingTokens: 0 + } + }); + } + } + `, + "utf8", + ); + NodeFS.writeFileSync( + binaryPath, + `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(scriptPath)}\n`, + "utf8", + ); + NodeFS.chmodSync(binaryPath, 0o755); + return { binaryPath, tempDir }; +} + +it.effect("fails observably when streamed Droid output exceeds the one-shot limit", () => + Effect.gen(function* () { + const { binaryPath, tempDir } = makeOversizedOutputDroid(); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const textGeneration = yield* makeDroidTextGeneration(decodeDroidSettings({ binaryPath })); + + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Generate a concise title", + modelSelection: createModelSelection(ProviderInstanceId.make("droid"), "mock-fast"), + }), + ); + + assert.equal(error._tag, "TextGenerationError"); + assert.include(error.detail, "output exceeded the 262144-character limit"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), +); diff --git a/apps/server/src/textGeneration/DroidTextGeneration.ts b/apps/server/src/textGeneration/DroidTextGeneration.ts new file mode 100644 index 000000000000..0dee53c6cc46 --- /dev/null +++ b/apps/server/src/textGeneration/DroidTextGeneration.ts @@ -0,0 +1,280 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DroidSettings, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { makeDroidRpcClient } from "../provider/droid/DroidRpcClient.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const DROID_TIMEOUT_MS = 180_000; +const SESSION_INIT_TIMEOUT_MS = 75_000; +const MAX_OUTPUT_CHARS = 256 * 1024; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeDroidTextGeneration = Effect.fn("makeDroidTextGeneration")(function* ( + droidSettings: DroidSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runDroidJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const failWith = (detail: string, cause?: unknown) => + new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); + const rpc = yield* makeDroidRpcClient({ + command: droidSettings.binaryPath, + args: ["exec", "--input-format", "stream-jsonrpc", "--output-format", "stream-jsonrpc"], + cwd, + env: environment, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), + Effect.mapError((cause) => failWith("Failed to start the Droid CLI.", cause)), + ); + + const outputChunks: string[] = []; + let outputLength = 0; + const turnDone = yield* Deferred.make(); + + // Collect assistant text and resolve on turn completion. The session is + // private to this request and carries exactly one user message, so the + // first completed turn is ours. + yield* Stream.runDrain( + Stream.mapEffect(rpc.notifications, ({ notification }) => { + switch (notification.type) { + case "assistant_text_delta": + return Effect.sync(() => { + const nextLength = outputLength + notification.textDelta.length; + if (nextLength > MAX_OUTPUT_CHARS) { + return false; + } + outputChunks.push(notification.textDelta); + outputLength = nextLength; + return true; + }).pipe( + Effect.flatMap((accepted) => + accepted + ? Effect.void + : Deferred.fail( + turnDone, + failWith(`Droid output exceeded the ${MAX_OUTPUT_CHARS}-character limit.`), + ).pipe(Effect.asVoid), + ), + ); + case "agent_turn_completed": + return Deferred.succeed(turnDone, notification.reason).pipe(Effect.asVoid); + default: + return Effect.void; + } + }), + ).pipe(Effect.forkScoped); + + const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + yield* rpc + .request( + "droid.initialize_session", + { + machineId: "default", + cwd, + autonomyLevel: "off", + interactionMode: "auto", + // Text generation must never touch the workspace. + restrictToolIds: [], + ...(modelSelection.model ? { modelId: modelSelection.model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }, + { timeoutMs: SESSION_INIT_TIMEOUT_MS }, + ) + .pipe(Effect.mapError((cause) => failWith("Failed to initialize Droid session.", cause))); + + yield* rpc + .request("droid.add_user_message", { text: prompt }) + .pipe(Effect.mapError((cause) => failWith("Droid rejected the prompt.", cause))); + + // Race the completion against process death: a crashed CLI must fail + // immediately, not ride out the full generation timeout. + const completionReason = yield* Effect.raceFirst( + Deferred.await(turnDone), + Effect.flatMap(rpc.exits, (exit) => + Effect.fail( + failWith(`Droid exited before completing the request (${exit.description}).`), + ), + ), + ).pipe( + Effect.timeoutOption(DROID_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(failWith("Droid request timed out.")), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const trimmed = outputChunks.join("").trim(); + if (!trimmed) { + return yield* failWith( + completionReason === "cancelled" + ? "Droid request was cancelled." + : "Droid returned empty output.", + ); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail(failWith("Droid returned invalid structured output.", cause)), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Droid text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("DroidTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runDroidJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("DroidTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runDroidJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("DroidTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runDroidJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("DroidTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runDroidJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..a63de752dac8 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode" + | "droid"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..77cd82ae2501 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,16 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const DroidIcon: Icon = ({ className, ...props }) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index d73f0f16b28f..0f7e246c7980 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -18,13 +18,17 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command approval" : approval.requestKind === "file-read" ? "File read approval" - : "File change approval"; + : approval.requestKind === "plan" + ? "Plan approval" + : "File change approval"; const detailAriaLabel = approval.requestKind === "command" ? "Command" : approval.requestKind === "file-read" ? "File to read" - : "File change"; + : approval.requestKind === "plan" + ? "Plan" + : "File change"; return (
> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("droid")]: DroidIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..d4f1368744ac 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode|cursor|droid)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..3da65be21af8 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + icon: DroidIcon, + badgeLabel: "Early Access", + settingsSchema: DroidSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..06e7384269cc 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,3 +1,4 @@ +import { resolveProviderDisplayName } from "@t3tools/client-runtime/providerDisplayName"; import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { @@ -28,23 +29,10 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { /** Map a provider driver kind to a user-facing display name. */ export function formatProviderDisplayName(provider: string | null | undefined): string { if (!provider) return "This agent"; - switch (provider) { - case "claudeAgent": - case "claude": - return "Claude"; - case "codex": - return "Codex"; - case "cursor": - return "Cursor"; - case "opencode": - return "OpenCode"; - default: { - // Title-case unknown driver kinds so they read reasonably. - const trimmed = provider.replace(/Agent$/i, "").trim(); - if (trimmed.length === 0) return provider; - return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); - } - } + const trimmed = provider.replace(/Agent$/i, "").trim(); + const fallback = + trimmed.length === 0 ? provider : trimmed.charAt(0).toUpperCase() + trimmed.slice(1); + return resolveProviderDisplayName(provider, fallback); } export function deriveLatestContextWindowSnapshot( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e94712d3e4da..69ec4dfa12e9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -130,6 +130,32 @@ describe("derivePendingApprovals", () => { ]); }); + it("maps plan approval requestType payloads into pending approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-plan-approval", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Plan approval requested", + tone: "approval", + payload: { + requestId: "req-plan-approval", + requestType: "plan_approval", + detail: "1. Map plan approvals\n2. Render the approval UI", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-plan-approval", + requestKind: "plan", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "1. Map plan approvals\n2. Render the approval UI", + }, + ]); + }); + it("derives dynamic tool requests as actionable generic approvals", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..a92249003f36 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,9 @@ import * as Option from "effect/Option"; import * as Arr from "effect/Array"; +import { + approvalRequestKindFromPayload, + type ApprovalRequestKind, +} from "@t3tools/client-runtime/approvalRequests"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; import { ApprovalRequestId, @@ -52,6 +56,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = @@ -109,7 +119,7 @@ interface DerivedWorkLogEntry extends WorkLogEntry { export interface PendingApproval { requestId: ApprovalRequestId; - requestKind: "command" | "file-read" | "file-change"; + requestKind: ApprovalRequestKind; createdAt: string; detail?: string; } @@ -367,22 +377,6 @@ export function deriveActiveWorkStartedAt( return sendStartedAt; } -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - case "dynamic_tool_call": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - default: - return null; - } -} - function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { const normalized = detail?.toLowerCase(); if (!normalized) { @@ -414,15 +408,7 @@ export function derivePendingApprovals( payload && typeof payload.requestId === "string" ? ApprovalRequestId.make(payload.requestId) : null; - const requestKind = - payload && - (payload.requestKind === "command" || - payload.requestKind === "file-read" || - payload.requestKind === "file-change") - ? payload.requestKind - : payload - ? requestKindFromRequestType(payload.requestType) - : null; + const requestKind = approvalRequestKindFromPayload(payload); const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; if (activity.kind === "approval.requested" && requestId && requestKind) { @@ -1671,14 +1657,7 @@ function extractWorkLogItemType( function extractWorkLogRequestKind( payload: Record | null, ): WorkLogEntry["requestKind"] | undefined { - if ( - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" - ) { - return payload.requestKind; - } - return requestKindFromRequestType(payload?.requestType) ?? undefined; + return approvalRequestKindFromPayload(payload) ?? undefined; } function pushChangedFile(target: string[], seen: Set, value: unknown) { diff --git a/docs/README.md b/docs/README.md index 622d81064387..9a3e1a472e81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,8 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · + [Droid](./user/providers-droid.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..4d13a954591c 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,14 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, +Cursor, Droid, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and +[CodexAdapter.ts][17] as a representative adapter. + +#### Factory home + +The per-user base directory used by Factory Droid for settings and credentials. It normally lives +under the operating-system user's home directory as `.factory`. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..9bc243b84bf5 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ -│ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ Agent CLIs: Codex, Claude, Cursor, Droid, │ +│ Grok, OpenCode │ └────────────────────────────────────────────────┘ ``` @@ -106,11 +106,11 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a -scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves -an instance to its adapter, so `ProviderService` routes session and turn operations without knowing -which agent is behind them. See [providers.md](./providers.md). +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Droid, Grok, and OpenCode. A driver declares its kind and config schema and +creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and +`ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session +and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). ## Checkpointing diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..20cf979450a3 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: -| Driver kind | Driver source | -| ------------- | --------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| Driver kind | Driver source | Adapter source | +| ------------- | --------------------------------------- | ----------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | `Layers/CodexAdapter.ts` | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | `Layers/ClaudeAdapter.ts` | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | `Layers/CursorAdapter.ts` | +| `droid` | [`Drivers/DroidDriver.ts`][droid] | [`Layers/DroidAdapter.ts`][droid-adapter] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | `Layers/GrokAdapter.ts` | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | `Layers/OpenCodeAdapter.ts` | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -79,6 +80,8 @@ when a request opens (approval) or user input is requested, via [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts +[droid]: ../../apps/server/src/provider/Drivers/DroidDriver.ts +[droid-adapter]: ../../apps/server/src/provider/Layers/DroidAdapter.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..255905427aaf 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,15 +54,18 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn +| Provider | CLI | Install | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | --------------------------------------------- | -------------- | --------------------------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | See provider instructions | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | See provider instructions | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | See provider instructions | `cursor-agent` | `agent login` | +| Droid | [Factory Droid](https://www.factory.ai/) | `curl -fsSL https://app.factory.ai/cli \| sh` | `droid` | Run `droid` and sign in in your browser | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | See provider instructions | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | See provider instructions | `opencode` | `opencode auth login` | + +On Windows, install Droid with `irm https://app.factory.ai/cli/windows | iex`. + +Codex, Claude, and Cursor are on by default. Droid, Grok Build, and OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that @@ -85,7 +88,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For provider-specific setup, see [Codex](./providers-codex.md), +[Claude](./providers-claude.md), and [Droid](./providers-droid.md). ## Next Steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 0648bafc8b77..41e8da4599aa 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -18,13 +18,17 @@ without prompting; commands and anything else still stop for approval. **Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto permission mode, and providers without an equivalent (such as OpenCode) fall back to asking, like -Supervised. +Supervised. Droid allows edits and read-only commands in **Auto-accept edits**, adds reversible +commands in **Auto**, and only runs every command without prompting in **Full access**. **Full access**: allow commands and edits without prompts. The default. The agent runs unattended until it finishes or asks a question of its own. -Approvals appear inline in the conversation. Approve or reject one and the agent continues from -there. +For Droid, **Full access** selects its highest autonomy level. T3 Code does not pass Droid's +`--skip-permissions-unsafe` override. + +Approvals appear inline in the conversation. Depending on the provider and request, rejecting one +may end the current turn instead of letting the agent continue in place. ## Choosing a Mode diff --git a/docs/user/providers-droid.md b/docs/user/providers-droid.md new file mode 100644 index 000000000000..fea39b117f88 --- /dev/null +++ b/docs/user/providers-droid.md @@ -0,0 +1,108 @@ +# Droid + +Droid is Factory's coding agent. T3 Code connects to the Factory Droid CLI on the machine running +the server, so you can use your own Factory subscription while working from the web, desktop, or +mobile app. + +Droid support is in Early Access. Enable it from the Droid provider card in Settings after +installing and authenticating the CLI. + +## Install And Log In + +Install Factory Droid. + +macOS and Linux: + +```bash +curl -fsSL https://app.factory.ai/cli | sh +``` + +Windows: + +```powershell +irm https://app.factory.ai/cli/windows | iex +``` + +Installations from these commands support automatic updates. Run `droid update` to check and update +manually. + +Then start Droid in a terminal: + +```bash +droid +``` + +Follow the browser sign-in flow. Run this on the machine that runs the T3 Code server. Droid stores +the resulting Factory account credentials in that user's Factory home. + +For automation, set `FACTORY_API_KEY` in the Droid provider's Environment variables section in +Settings. Mark it as sensitive so T3 Code stores it as a server secret and does not send it back to +the app after saving. When both are present, the API key takes precedence over the stored Factory +account login. + +## Models And Reasoning + +T3 Code fetches the available models from Droid dynamically. Each model advertises the reasoning +efforts it supports, and those choices appear with the model in the picker. The list can change as +Factory adds or updates models without requiring a T3 Code update. + +You can change the model or reasoning effort in an existing thread. T3 Code applies the new choice +before it sends the next message to Droid. + +## Slash Commands And Skills + +T3 Code reads your Droid slash commands and skills when it checks the provider, so they appear in the +composer alongside every other provider's. Custom commands keep their argument hints, and skills keep +their descriptions and source. Skills Droid does not let you invoke directly, such as its built-ins, +stay out of the list. + +Commands and skills resolve on the machine running the server against the server's working +directory, so project-local entries are discovered alongside personal ones. Add a command or skill, +refresh the Droid card in Settings, and it shows up. + +## Permission Modes + +T3 Code maps its permission modes onto Droid's command confirmation levels: + +| T3 Code mode | Droid behavior | +| ------------------------------ | ------------------------------------------------- | +| Supervised (approval required) | Confirms every command and file change | +| Auto-accept edits | Automatically allows edits and read-only commands | +| Auto | Also allows reversible commands without prompting | +| Full access | Allows all commands without prompting | + +Approvals appear inline in the conversation. Rejecting one cancels the current turn; send another +message to tell Droid how to proceed. + +## Plan Mode + +When T3 Code's plan mode is enabled, it uses Droid's Spec Mode. Droid researches and writes a plan +before implementation, then presents the plan approval as an approval request in the conversation. +Approve it to begin implementation. Rejecting it cancels the turn; send another message in plan mode +to refine the plan. On approval, Droid hands the work to an implementation session in the same +thread; the turn keeps streaming and the thread resumes onto the implementation conversation +afterwards. + +## Context And Subagents + +Droid compacts long conversations automatically, so the context meter shows the live context after +compaction rather than lifetime usage. When Droid delegates work to a subagent, it appears as a task +in the conversation with its own completion state. + +If you send another message while Droid is working, T3 Code treats it as steering for the active +turn. Droid may fold it into the current run or process it immediately afterwards. + +## Session Resume + +Droid sessions resume across T3 Code server restarts. Reopen the same thread and continue where you +left off instead of starting a new Droid conversation. + +After a session resumes, rollback can only target turns completed since T3 Code most recently loaded +that Droid session. Earlier turns remain in the conversation, but T3 Code cannot use them as Droid +rollback points. + +## Early Access + +Droid support is still evolving. Model metadata, reasoning choices, approval behavior, and session +resume may change as the Factory CLI develops. If a session behaves unexpectedly, update Factory +Droid, refresh its status in Settings, and start a new thread if the existing session cannot resume. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998966..deaab5de3a44 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -43,6 +43,14 @@ "types": "./src/providerSkills.ts", "default": "./src/providerSkills.ts" }, + "./approvalRequests": { + "types": "./src/approvalRequests.ts", + "default": "./src/approvalRequests.ts" + }, + "./providerDisplayName": { + "types": "./src/providerDisplayName.ts", + "default": "./src/providerDisplayName.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" diff --git a/packages/client-runtime/src/approvalRequests.ts b/packages/client-runtime/src/approvalRequests.ts new file mode 100644 index 000000000000..c7cf4461c18f --- /dev/null +++ b/packages/client-runtime/src/approvalRequests.ts @@ -0,0 +1,36 @@ +export type ApprovalRequestKind = "command" | "file-read" | "file-change" | "plan"; + +/** + * Reads the client-facing approval kind from an orchestration activity payload. + * Dynamic tool calls use the generic executable-action bucket so they remain + * actionable on clients that do not render provider-specific tool kinds. + */ +export function approvalRequestKindFromPayload( + payload: Readonly> | null | undefined, +): ApprovalRequestKind | null { + const requestKind = payload?.requestKind; + if ( + requestKind === "command" || + requestKind === "file-read" || + requestKind === "file-change" || + requestKind === "plan" + ) { + return requestKind; + } + + switch (payload?.requestType) { + case "command_execution_approval": + case "exec_command_approval": + case "dynamic_tool_call": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + case "plan_approval": + return "plan"; + default: + return null; + } +} diff --git a/packages/client-runtime/src/providerDisplayName.test.ts b/packages/client-runtime/src/providerDisplayName.test.ts new file mode 100644 index 000000000000..9eeb890776e3 --- /dev/null +++ b/packages/client-runtime/src/providerDisplayName.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveProviderDisplayName } from "./providerDisplayName.ts"; + +describe("resolveProviderDisplayName", () => { + it("uses canonical built-in names, including the historical claude alias", () => { + expect(resolveProviderDisplayName("droid", "droid")).toBe("Droid"); + expect(resolveProviderDisplayName("grok", "grok")).toBe("Grok"); + expect(resolveProviderDisplayName("claude", "claude")).toBe("Claude"); + }); + + it("preserves the caller's fallback for custom drivers", () => { + expect(resolveProviderDisplayName("acmeAgent", "Acme Agent")).toBe("Acme Agent"); + }); +}); diff --git a/packages/client-runtime/src/providerDisplayName.ts b/packages/client-runtime/src/providerDisplayName.ts new file mode 100644 index 000000000000..364082417d80 --- /dev/null +++ b/packages/client-runtime/src/providerDisplayName.ts @@ -0,0 +1,10 @@ +import { PROVIDER_DISPLAY_NAMES, ProviderDriverKind } from "@t3tools/contracts"; + +/** + * Resolves a raw driver slug through the canonical built-in display-name table. + * Callers provide the surface-appropriate fallback for custom drivers. + */ +export function resolveProviderDisplayName(driver: string, fallback: string): string { + const driverKind = ProviderDriverKind.make(driver === "claude" ? "claudeAgent" : driver); + return PROVIDER_DISPLAY_NAMES[driverKind] ?? fallback; +} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..bd671c9c0528 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const DROID_DRIVER_KIND = ProviderDriverKind.make("droid"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [DROID_DRIVER_KIND]: "Droid", }; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..b718bac4d28e 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,6 +26,8 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("droid.jsonrpc.notification"), + Schema.Literal("droid.jsonrpc.request"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); @@ -138,6 +140,7 @@ export const CanonicalRequestType = Schema.Literals([ "file_change_approval", "apply_patch_approval", "exec_command_approval", + "plan_approval", "tool_user_input", "dynamic_tool_call", "auth_tokens_refresh", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..68484bba73b9 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -203,12 +203,14 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.droid.enabled).toBe(false); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("droid"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ba4facaf53ce..2505ac873606 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -530,6 +530,33 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DroidSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor, Grok, and OpenCode): the binding is not + // yet stable enough to probe on every install. Users opt in from + // Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("droid").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Factory Droid CLI binary.", + 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(""))), @@ -672,6 +699,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + droid: DroidSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -819,6 +847,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 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -860,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + droid: Schema.optionalKey(DroidSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual