diff --git a/apps/server/package.json b/apps/server/package.json index 387c880a9ab..d81a6f8155c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@factory/droid-sdk": "^0.2.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/provider/Drivers/DroidDriver.ts b/apps/server/src/provider/Drivers/DroidDriver.ts new file mode 100644 index 00000000000..0fab3b30878 --- /dev/null +++ b/apps/server/src/provider/Drivers/DroidDriver.ts @@ -0,0 +1,157 @@ +import { + DroidSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDroidAdapter } from "../Layers/DroidAdapter.ts"; +import { checkDroidProviderStatus, makePendingDroidProvider } from "../Layers/DroidProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; + +const decodeDroidSettings = Schema.decodeSync(DroidSettings); +const DRIVER_KIND = ProviderDriverKind.make("droid"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "droid", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type DroidDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +function makeUnsupportedTextGeneration(): TextGenerationShape { + const fail = (operation: TextGenerationError["operation"]) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Droid SDK text generation is not enabled in this WIP.", + }), + ); + return { + generateCommitMessage: () => fail("generateCommitMessage"), + generatePrContent: () => fail("generatePrContent"), + generateBranchName: () => fail("generateBranchName"), + generateThreadTitle: () => fail("generateThreadTitle"), + }; +} + +export const DroidDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Droid", + supportsMultipleInstances: true, + }, + configSchema: DroidSettings, + defaultConfig: (): DroidSettings => decodeDroidSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DroidSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeDroidAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + }); + const checkProvider = checkDroidProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingDroidProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Droid snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 7f71ef46b2c..e4c610f7e42 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -269,6 +269,7 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { sandbox: "read-only", }; case "auto-accept-edits": + case "medium-access": return { approvalPolicy: "on-request", sandbox: "workspace-write", @@ -307,6 +308,7 @@ function runtimeModeToTurnSandboxPolicy( type: "readOnly", }; case "auto-accept-edits": + case "medium-access": return { type: "workspaceWrite", }; diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts new file mode 100644 index 00000000000..4b949669275 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -0,0 +1,942 @@ +import assert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + AutonomyLevel, + DroidErrorType, + DroidInteractionMode, + DroidMessageType, + DroidWorkingState, + type MessageOptions, + ReasoningEffort, + ToolConfirmationOutcome, + ToolConfirmationType, + type CreateSessionOptions, + type DroidMessage, + type DroidSession, + type RequestPermissionRequestParams, +} from "@factory/droid-sdk"; +import { + ApprovalRequestId, + DroidSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import { makeDroidAdapter } from "./DroidAdapter.ts"; + +const settings = Schema.decodeSync(DroidSettings)({ + enabled: true, + binaryPath: "fake-droid", +}); +const threadId = ThreadId.make("thread-droid"); + +function fakeSession(input: { + readonly sessionId?: string; + readonly messages?: ReadonlyArray; + readonly onStream?: (options?: MessageOptions) => AsyncGenerator; + readonly onClose?: () => Promise; + readonly onInterrupt?: () => Promise; + readonly onEnterSpecMode?: (params: unknown) => void; + readonly onUpdateSettings?: (params: unknown) => void; +}): DroidSession { + return { + sessionId: input.sessionId ?? "droid-session-1", + initResult: { sessionId: input.sessionId ?? "droid-session-1" }, + stream: (_text: string, options?: MessageOptions) => + input.onStream?.(options) ?? + (async function* () { + for (const message of input.messages ?? []) { + yield message; + } + })(), + send: async () => ({ + sessionId: input.sessionId ?? "droid-session-1", + text: "", + messages: [], + tokenUsage: null, + durationMs: 0, + turnCount: 1, + error: null, + structuredOutput: null, + success: true, + }), + interrupt: input.onInterrupt ?? (async () => undefined), + close: input.onClose ?? (async () => undefined), + updateSettings: async (params: unknown) => { + input.onUpdateSettings?.(params); + return {}; + }, + enterSpecMode: async (params: unknown) => { + input.onEnterSpecMode?.(params); + return {}; + }, + } as unknown as DroidSession; +} + +const testLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( + Layer.provideMerge(NodeServices.layer), +); + +it.effect("maps Droid SDK stream messages into canonical runtime events", () => + Effect.scoped( + Effect.gen(function* () { + let createOptions: CreateSessionOptions | undefined; + const adapter = yield* makeDroidAdapter(settings, { + instanceId: ProviderInstanceId.make("droid"), + sdk: { + createSession: async (options) => { + createOptions = options; + return fakeSession({ + messages: [ + { + type: DroidMessageType.AssistantTextDelta, + messageId: "m1", + blockIndex: 0, + text: "hi", + }, + { + type: DroidMessageType.ToolUse, + toolName: "Execute", + toolInput: {}, + toolUseId: "tool-1", + }, + { + type: DroidMessageType.ToolProgress, + toolName: "Execute", + toolUseId: "tool-1", + content: "running", + update: { type: "status", status: "running", text: "running" }, + }, + { + type: DroidMessageType.ToolResult, + toolName: "Execute", + toolUseId: "tool-1", + content: "done", + isError: false, + }, + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 10, + outputTokens: 4, + cacheCreationTokens: 2, + cacheReadTokens: 3, + thinkingTokens: 1, + }, + { type: DroidMessageType.SessionTitleUpdated, title: "Droid title" }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }); + }, + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(11), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: createModelSelection(ProviderInstanceId.make("droid"), "claude-sonnet", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + assert.equal(createOptions?.modelId, "claude-sonnet"); + assert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); + assert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); + assert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.started", + "thread.started", + "turn.started", + "content.delta", + "item.started", + "item.updated", + "item.completed", + "thread.token-usage.updated", + "thread.metadata.updated", + "item.completed", + "turn.completed", + ], + ); + const expectedUsage = { + usedTokens: 20, + inputTokens: 15, + cachedInputTokens: 3, + outputTokens: 5, + reasoningOutputTokens: 1, + lastUsedTokens: 20, + lastInputTokens: 15, + lastCachedInputTokens: 3, + lastOutputTokens: 5, + lastReasoningOutputTokens: 1, + }; + assert.deepEqual( + events.find((event) => event.type === "thread.token-usage.updated")?.payload, + { usage: expectedUsage }, + ); + assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + state: "completed", + usage: expectedUsage, + }); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("keeps Droid token usage cumulative across turns", () => + Effect.scoped( + Effect.gen(function* () { + const usageThreadId = ThreadId.make("thread-droid-token-usage"); + let streamCalls = 0; + const turnUsages: ReadonlyArray = [ + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 10, + outputTokens: 4, + cacheCreationTokens: 2, + cacheReadTokens: 3, + thinkingTokens: 1, + }, + { + type: DroidMessageType.TokenUsageUpdate, + inputTokens: 5, + outputTokens: 7, + cacheCreationTokens: 0, + cacheReadTokens: 1, + thinkingTokens: 2, + }, + ]; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + onStream: async function* () { + const usage = turnUsages[streamCalls]; + streamCalls += 1; + if (!usage) throw new Error("Unexpected extra Droid turn stream."); + yield usage; + yield { type: DroidMessageType.TurnComplete, tokenUsage: null }; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const firstEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === usageThreadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: usageThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: usageThreadId, input: "first" }); + const firstEvents = Array.from( + yield* Fiber.join(firstEventsFiber).pipe(Effect.timeout("2 seconds")), + ); + + const secondEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === usageThreadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ threadId: usageThreadId, input: "second" }); + const secondEvents = Array.from( + yield* Fiber.join(secondEventsFiber).pipe(Effect.timeout("2 seconds")), + ); + const events = [...firstEvents, ...secondEvents]; + const usageEvents = events.filter((event) => event.type === "thread.token-usage.updated"); + const completedTurns = events.filter((event) => event.type === "turn.completed"); + + assert.deepEqual( + usageEvents.map((event) => + event.type === "thread.token-usage.updated" ? event.payload.usage : undefined, + ), + [ + { + usedTokens: 20, + inputTokens: 15, + cachedInputTokens: 3, + outputTokens: 5, + reasoningOutputTokens: 1, + lastUsedTokens: 20, + lastInputTokens: 15, + lastCachedInputTokens: 3, + lastOutputTokens: 5, + lastReasoningOutputTokens: 1, + }, + { + usedTokens: 35, + inputTokens: 21, + cachedInputTokens: 4, + outputTokens: 14, + reasoningOutputTokens: 3, + lastUsedTokens: 15, + lastInputTokens: 6, + lastCachedInputTokens: 1, + lastOutputTokens: 9, + lastReasoningOutputTokens: 2, + }, + ], + ); + assert.deepEqual( + completedTurns.map((event) => + event.type === "turn.completed" + ? (event.payload as { usage?: { usedTokens?: number } }).usage?.usedTokens + : undefined, + ), + [20, 35], + ); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("maps Droid medium access to medium autonomy", () => + Effect.scoped( + Effect.gen(function* () { + let createOptions: CreateSessionOptions | undefined; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async (options) => { + createOptions = options; + return fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + }); + }, + resumeSession: async () => fakeSession({}), + }, + }); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "medium-access", + }); + + assert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("updates Droid settings when resuming an existing session", () => + Effect.scoped( + Effect.gen(function* () { + let updateSettingsParams: unknown; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => fakeSession({}), + resumeSession: async () => + fakeSession({ + onUpdateSettings: (params) => { + updateSettingsParams = params; + }, + }), + }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "medium-access", + resumeCursor: "droid-session-existing", + modelSelection: createModelSelection(ProviderInstanceId.make("droid"), "custom-model", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + + assert.deepEqual(updateSettingsParams, { + autonomyLevel: AutonomyLevel.Medium, + interactionMode: DroidInteractionMode.Auto, + modelId: "custom-model", + reasoningEffort: ReasoningEffort.High, + }); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("closes the previous Droid session before replacing a thread context", () => + Effect.scoped( + Effect.gen(function* () { + const closedSessionIds: string[] = []; + let createCalls = 0; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => { + createCalls += 1; + const sessionId = `droid-session-${createCalls}`; + return fakeSession({ + sessionId, + onClose: async () => { + closedSessionIds.push(sessionId); + }, + }); + }, + resumeSession: async () => fakeSession({}), + }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + + assert.deepEqual(closedSessionIds, ["droid-session-1"]); + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.resumeCursor, "droid-session-2"); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("uses final Droid create_message content when deltas are absent", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-final", + role: "assistant", + content: [ + { type: "thinking", signature: "test-signature", thinking: "final thought" }, + { type: "text", text: "final text" }, + ], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(6), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), + [ + { streamKind: "reasoning_text", delta: "final thought" }, + { streamKind: "assistant_text", delta: "final text" }, + ], + ); + const completed = events.find((event) => event.type === "item.completed"); + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.itemType, "assistant_message"); + assert.equal(completed.payload.detail, "final text"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("does not duplicate Droid final create_message text after streaming deltas", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.AssistantTextDelta, + messageId: "assistant-streamed", + blockIndex: 0, + text: "stre", + }, + { + type: DroidMessageType.AssistantTextDelta, + messageId: "assistant-streamed", + blockIndex: 0, + text: "am", + }, + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-streamed", + role: "assistant", + content: [{ type: "text", id: "sdk-text-block", text: "stream" }], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(7), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : undefined)), + ["stre", "am"], + ); + const completed = events.find((event) => event.type === "item.completed"); + assert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + assert.equal(completed.payload.detail, "stream"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("does not duplicate Droid final thinking content after streaming deltas", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.ThinkingTextDelta, + messageId: "assistant-thinking", + blockIndex: 0, + text: "thi", + }, + { + type: DroidMessageType.ThinkingTextDelta, + messageId: "assistant-thinking", + blockIndex: 0, + text: "nk", + }, + { + type: DroidMessageType.CreateMessage, + messageId: "assistant-thinking", + role: "assistant", + content: [ + { + type: "thinking", + id: "sdk-thinking-block", + signature: "test-signature", + thinking: "think", + }, + { type: "text", text: "answer" }, + ], + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(8), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + const deltas = events.filter((event) => event.type === "content.delta"); + assert.deepEqual( + deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), + [ + { streamKind: "reasoning_text", delta: "thi" }, + { streamKind: "reasoning_text", delta: "nk" }, + { streamKind: "assistant_text", delta: "answer" }, + ], + ); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("ignores Droid interrupt failures after aborting the active turn", () => + Effect.scoped( + Effect.gen(function* () { + let interruptAttempts = 0; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + onInterrupt: async () => { + interruptAttempts += 1; + throw new Error("interrupt failed"); + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + + const exit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); + assert.equal(exit._tag, "Success"); + assert.equal(interruptAttempts, 1); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("completes aborted Droid streams as interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + onStream: async function* (options) { + yield { + type: DroidMessageType.WorkingStateChanged, + state: DroidWorkingState.StreamingAssistantMessage, + }; + while (!options?.abortSignal?.aborted) { + await Promise.resolve(); + } + throw new DOMException("The operation was aborted.", "AbortError"); + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "stop me" }); + yield* adapter.interruptTurn(threadId); + + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal(completed.value.payload.state, "interrupted"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("fails Droid turns when the SDK stream emits an error message", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [ + { + type: DroidMessageType.Error, + message: "model exploded", + errorType: DroidErrorType.ERROR, + timestamp: "1970-01-01T00:00:00.000Z", + }, + { type: DroidMessageType.TurnComplete, tokenUsage: null }, + ], + }), + resumeSession: async () => fakeSession({}), + }, + }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type !== "session.started" && + event.type !== "thread.started", + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "fail" }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); + assert.deepEqual( + events.map((event) => event.type), + ["turn.started", "runtime.error", "turn.completed"], + ); + const completed = events.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "model exploded"); + } + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("passes custom model reasoning into Droid spec mode", () => + Effect.scoped( + Effect.gen(function* () { + let enterSpecModeParams: unknown; + let updateSettingsParams: unknown; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + onEnterSpecMode: (params) => { + enterSpecModeParams = params; + }, + onUpdateSettings: (params) => { + updateSettingsParams = params; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "plan", + interactionMode: "plan", + modelSelection: createModelSelection( + ProviderInstanceId.make("droid"), + "custom:Direct-GPT-5.5-xhigh-27", + [{ id: "reasoningEffort", value: "xhigh" }], + ), + }); + + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(completed._tag, "Some"); + assert.deepEqual(enterSpecModeParams, { + specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", + specModeReasoningEffort: ReasoningEffort.ExtraHigh, + }); + assert.deepEqual(updateSettingsParams, { + modelId: "custom:Direct-GPT-5.5-xhigh-27", + reasoningEffort: ReasoningEffort.ExtraHigh, + specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", + specModeReasoningEffort: ReasoningEffort.ExtraHigh, + }); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("routes Droid permission requests through adapter approvals", () => + Effect.scoped( + Effect.gen(function* () { + let permissionResult: string | undefined; + const permissionParams: RequestPermissionRequestParams = { + options: [ + { label: "Proceed once", value: ToolConfirmationOutcome.ProceedOnce }, + { label: "Cancel", value: ToolConfirmationOutcome.Cancel }, + ], + toolUses: [ + { + toolUse: { type: "tool_use", id: "tool-1", input: {}, name: "Execute" }, + confirmationType: ToolConfirmationType.Execute, + details: { + type: ToolConfirmationType.Execute, + fullCommand: "bun lint", + command: "bun", + }, + }, + ], + }; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async (options) => + fakeSession({ + onStream: async function* () { + const result = await options?.permissionHandler?.(permissionParams); + permissionResult = typeof result === "string" ? result : result?.selectedOption; + yield { type: DroidMessageType.TurnComplete, tokenUsage: null }; + }, + }), + resumeSession: async () => fakeSession({}), + }, + }); + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "run lint" }); + const opened = yield* Fiber.join(openedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(opened._tag, "Some"); + const requestId = opened.value.requestId; + assert.ok(requestId); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(requestId), + "acceptForSession", + ); + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); + assert.equal(completed._tag, "Some"); + assert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("continues stopping Droid sessions when one close fails", () => + Effect.scoped( + Effect.gen(function* () { + const closedSessionIds: string[] = []; + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => fakeSession({}), + resumeSession: async (sessionId) => + fakeSession({ + sessionId, + onClose: async () => { + closedSessionIds.push(sessionId); + if (sessionId === "droid-session-fails-close") { + throw new Error("close failed"); + } + }, + }), + }, + }); + + const firstThreadId = ThreadId.make("thread-droid-close-1"); + const secondThreadId = ThreadId.make("thread-droid-close-2"); + yield* adapter.startSession({ + threadId: firstThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + resumeCursor: "droid-session-fails-close", + }); + yield* adapter.startSession({ + threadId: secondThreadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + resumeCursor: "droid-session-closes", + }); + + yield* adapter.stopAll(); + + assert.deepEqual(closedSessionIds.toSorted(), [ + "droid-session-closes", + "droid-session-fails-close", + ]); + const sessions = yield* adapter.listSessions(); + assert.deepEqual(sessions, []); + }), + ).pipe(Effect.provide(testLayer)), +); + +it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeDroidAdapter(settings, { + sdk: { + createSession: async () => + fakeSession({ + messages: [{ type: DroidMessageType.TurnComplete, tokenUsage: null }], + }), + resumeSession: async () => fakeSession({}), + }, + }); + + const missing = yield* adapter + .readThread(ThreadId.make("missing-droid-thread")) + .pipe(Effect.exit); + assert.equal(missing._tag, "Failure"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("droid"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "first" }); + yield* adapter.sendTurn({ threadId, input: "second" }); + + const before = yield* adapter.readThread(threadId); + assert.equal(before.turns.length, 2); + const unsupported = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); + assert.equal(unsupported._tag, "Failure"); + const after = yield* adapter.readThread(threadId); + assert.equal(after.turns.length, 2); + + const invalid = yield* adapter.rollbackThread(threadId, 0).pipe(Effect.exit); + assert.equal(invalid._tag, "Failure"); + }), + ).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts new file mode 100644 index 00000000000..65362b71279 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -0,0 +1,497 @@ +import { randomUUID } from "node:crypto"; +import { + type AskUserRequestParams, + type AskUserResult, + createSession, + type MessageOptions, + resumeSession, + ToolConfirmationOutcome, + type RequestPermissionRequestParams, +} from "@factory/droid-sdk"; +import { + ApprovalRequestId, + ProviderInstanceId, + ThreadId, + TurnId, + type DroidSettings, + type ProviderRuntimeEvent, + type ProviderSession, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { resolveDroidImages } from "../droid/DroidAttachmentResolver.ts"; +import { + DROID_PROVIDER, + type DroidAdapterOptions, + type DroidAdapterShape, + type DroidContext, +} from "../droid/DroidAdapterTypes.ts"; +import { + handleDroidMessage, + makeDroidEventBase, + nowIso, + updateDroidContextSession, +} from "../droid/DroidRuntimeEvents.ts"; +import { + DroidInteractionMode, + normalizeAskUserQuestions, + permissionDetail, + toAskUserResult, + toAutonomyLevel, + toModelId, + toOutcome, + toReasoningEffort, + toRequestType, +} from "../droid/DroidSdkMappings.ts"; + +export type { DroidAdapterOptions } from "../droid/DroidAdapterTypes.ts"; + +const INTERRUPTED_TURN_MESSAGE = "Droid turn interrupted."; + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback; +} + +export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapterOptions) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const sdk = options?.sdk ?? { createSession, resumeSession }; + const instanceId = options?.instanceId ?? ProviderInstanceId.make("droid"); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + const env = Object.fromEntries( + Object.entries({ ...process.env, ...options?.environment }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + yield* Effect.forEach( + contexts, + (context) => + Effect.tryPromise(() => { + context.activeAbort?.abort(); + return context.droid.close(); + }).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + yield* Queue.shutdown(runtimeEvents); + }), + ); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitNow = (event: ProviderRuntimeEvent) => runPromise(emit(event)); + const eventBase = makeDroidEventBase(instanceId); + const requireSession = Effect.fn("requireDroidSession")(function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: DROID_PROVIDER, + threadId, + }); + } + return context; + }); + const closeContext = (context: DroidContext) => + Effect.tryPromise(() => { + context.activeAbort?.abort(); + return context.droid.close(); + }).pipe(Effect.ignore); + + const startSession: DroidAdapterShape["startSession"] = Effect.fn("startDroidSession")( + function* (input) { + let contextRef: DroidContext | undefined; + const permissionHandler = (params: RequestPermissionRequestParams) => + new Promise((resolve) => { + const context = contextRef; + if (!context) { + resolve(ToolConfirmationOutcome.Cancel); + return; + } + const requestId = ApprovalRequestId.make(`droid-${randomUUID()}`); + const requestType = toRequestType(params); + context.pendingPermissions.set(requestId, { requestType, resolve }); + void emitNow({ + ...eventBase(context, { requestId, raw: params }), + raw: { source: "droid.sdk.permission", payload: params }, + type: "request.opened", + payload: { + requestType, + detail: permissionDetail(params), + args: params, + }, + }); + }); + const askUserHandler = (params: AskUserRequestParams) => + new Promise((resolve) => { + const context = contextRef; + if (!context) { + resolve({ cancelled: true, answers: [] }); + return; + } + const requestId = ApprovalRequestId.make(`droid-question-${randomUUID()}`); + const questions = normalizeAskUserQuestions(params); + context.pendingUserInputs.set(requestId, { + questions, + droidQuestions: params.questions, + resolve, + }); + void emitNow({ + ...eventBase(context, { requestId, raw: params }), + raw: { source: "droid.sdk.permission", payload: params }, + type: "user-input.requested", + payload: { questions }, + }); + }); + const modelSelection = input.modelSelection; + const modelId = toModelId(modelSelection?.model); + const sdkOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + execPath: settings.binaryPath, + env, + permissionHandler, + askUserHandler, + }; + const autonomyLevel = toAutonomyLevel(input); + const reasoningEffort = toReasoningEffort( + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"), + ); + const droid = yield* Effect.tryPromise({ + try: async (abortSignal) => { + const sessionOptions = { ...sdkOptions, abortSignal }; + if (typeof input.resumeCursor === "string") { + const session = await sdk.resumeSession(input.resumeCursor, sessionOptions); + await session.updateSettings({ + autonomyLevel, + interactionMode: DroidInteractionMode.Auto, + ...(modelId ? { modelId } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + return session; + } + return sdk.createSession({ + ...sessionOptions, + ...(modelId ? { modelId } : {}), + autonomyLevel, + interactionMode: DroidInteractionMode.Auto, + ...(reasoningEffort ? { reasoningEffort } : {}), + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "createSession", + detail: cause instanceof Error ? cause.message : "Failed to start Droid session.", + cause, + }), + }); + const session: ProviderSession = { + provider: DROID_PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + model: modelSelection?.model ?? "default", + threadId: input.threadId, + resumeCursor: droid.sessionId, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const context: DroidContext = { + session, + droid, + pendingPermissions: new Map(), + pendingUserInputs: new Map(), + turns: [], + activeAbort: undefined, + activeAssistantItems: new Map(), + activeThinkingItems: new Map(), + activeCompletedAssistantItems: new Set(), + activeTurnError: undefined, + activeTokenUsage: undefined, + activeTokenUsageBaseline: undefined, + cumulativeTokenUsage: undefined, + }; + contextRef = context; + const previousContext = sessions.get(input.threadId); + if (previousContext) { + yield* closeContext(previousContext); + } + sessions.set(input.threadId, context); + + yield* emit({ + ...eventBase(context), + type: "session.started", + payload: { message: "Droid SDK session started" }, + }); + yield* emit({ + ...eventBase(context), + type: "thread.started", + payload: { providerThreadId: droid.sessionId }, + }); + return session; + }, + ); + + const sendTurn: DroidAdapterShape["sendTurn"] = Effect.fn("sendDroidTurn")(function* (input) { + const context = sessions.get(input.threadId); + if (!context) { + return yield* new ProviderAdapterValidationError({ + provider: DROID_PROVIDER, + operation: "sendTurn", + issue: `Unknown Droid thread: ${input.threadId}`, + }); + } + const text = input.input?.trim(); + const images = yield* resolveDroidImages(input.attachments ?? [], { + attachmentsDir: serverConfig.attachmentsDir, + fileSystem, + }); + if (!text && images.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: DROID_PROVIDER, + operation: "sendTurn", + issue: "Droid turns require text input or at least one attachment.", + }); + } + + const turnId = TurnId.make(`droid-turn-${randomUUID()}`); + const abort = new AbortController(); + context.activeAbort = abort; + context.activeAssistantItems = new Map(); + context.activeThinkingItems = new Map(); + context.activeCompletedAssistantItems = new Set(); + context.activeTurnError = undefined; + context.activeTokenUsage = undefined; + context.activeTokenUsageBaseline = context.cumulativeTokenUsage; + context.turns.push({ id: turnId, items: [] }); + updateDroidContextSession(context, { + status: "running", + activeTurnId: turnId, + model: input.modelSelection?.model ?? context.session.model, + }); + + yield* emit({ + ...eventBase(context, { turnId }), + type: "turn.started", + payload: { model: context.session.model }, + }); + + yield* Effect.promise(async () => { + const completeInterruptedTurn = async () => { + updateDroidContextSession(context, { status: "ready", activeTurnId: undefined }); + await emitNow({ + ...eventBase(context, { turnId }), + type: "turn.completed", + payload: { state: "interrupted", stopReason: INTERRUPTED_TURN_MESSAGE }, + }); + }; + const completeFailedTurn = async (message: string, emitRuntimeError: boolean) => { + updateDroidContextSession(context, { + status: "error", + activeTurnId: undefined, + lastError: message, + }); + if (emitRuntimeError) { + await emitNow({ + ...eventBase(context, { turnId }), + type: "runtime.error", + payload: { message, class: "provider_error" }, + }); + } + await emitNow({ + ...eventBase(context, { turnId }), + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + }; + try { + const modelId = toModelId(input.modelSelection?.model); + const reasoningEffort = toReasoningEffort( + getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort"), + ); + if (input.interactionMode === "plan") { + await context.droid.enterSpecMode({ + ...(modelId ? { specModeModelId: modelId } : {}), + ...(reasoningEffort ? { specModeReasoningEffort: reasoningEffort } : {}), + }); + } + if (modelId || reasoningEffort) { + await context.droid.updateSettings({ + ...(modelId ? { modelId } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(input.interactionMode === "plan" && modelId ? { specModeModelId: modelId } : {}), + ...(input.interactionMode === "plan" && reasoningEffort + ? { specModeReasoningEffort: reasoningEffort } + : {}), + }); + } + const messageOptions: MessageOptions = { + abortSignal: abort.signal, + ...(images.length > 0 ? { images } : {}), + }; + for await (const message of context.droid.stream( + text || "Please respond to the attached image.", + messageOptions, + )) { + await handleDroidMessage({ context, turnId, message, eventBase, emitNow }); + } + if (abort.signal.aborted) { + await completeInterruptedTurn(); + return; + } + if (context.activeTurnError) { + await completeFailedTurn(context.activeTurnError, false); + return; + } + for (const [itemId, detail] of context.activeAssistantItems) { + if (context.activeCompletedAssistantItems.has(itemId)) { + continue; + } + await emitNow({ + ...eventBase(context, { turnId, itemId }), + type: "item.completed", + payload: { itemType: "assistant_message", status: "completed", detail }, + }); + } + updateDroidContextSession(context, { status: "ready", activeTurnId: undefined }); + await emitNow({ + ...eventBase(context, { turnId }), + type: "turn.completed", + payload: { + state: "completed", + ...(context.activeTokenUsage ? { usage: context.activeTokenUsage } : {}), + }, + }); + } catch (cause) { + if (abort.signal.aborted) { + await completeInterruptedTurn(); + return; + } + await completeFailedTurn(errorMessage(cause, "Droid turn failed."), true); + } finally { + if (context.activeAbort === abort) { + context.activeAbort = undefined; + } + } + }).pipe(Effect.forkDetach); + + return { threadId: input.threadId, turnId, resumeCursor: context.droid.sessionId }; + }); + + const stopSession = (threadId: ThreadId) => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) return; + sessions.delete(threadId); + yield* closeContext(context); + yield* emit({ + ...eventBase(context), + type: "session.exited", + payload: { reason: "Session stopped", recoverable: false, exitKind: "graceful" }, + }); + }); + + return { + provider: DROID_PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn: (threadId) => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) return; + context.activeAbort?.abort(); + yield* Effect.tryPromise(() => context.droid.interrupt()).pipe(Effect.ignore); + }), + respondToRequest: (threadId, requestId, decision) => + Effect.gen(function* () { + const context = sessions.get(threadId); + const pending = context?.pendingPermissions.get(requestId); + if (!context || !pending) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "respondToRequest", + detail: `Unknown pending Droid permission request: ${requestId}`, + }); + } + context.pendingPermissions.delete(requestId); + pending.resolve(toOutcome(decision)); + yield* emit({ + ...eventBase(context, { requestId }), + type: "request.resolved", + payload: { requestType: pending.requestType, decision }, + }); + }), + respondToUserInput: (threadId, requestId, answers) => + Effect.gen(function* () { + const context = sessions.get(threadId); + const pending = context?.pendingUserInputs.get(requestId); + if (!context || !pending) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "respondToUserInput", + detail: `Unknown pending Droid user-input request: ${requestId}`, + }); + } + context.pendingUserInputs.delete(requestId); + pending.resolve(toAskUserResult(pending.droidQuestions, answers)); + yield* emit({ + ...eventBase(context, { requestId }), + type: "user-input.resolved", + payload: { answers }, + }); + }), + stopSession, + listSessions: () => Effect.succeed([...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.succeed(sessions.has(threadId)), + readThread: (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return { threadId, turns: context.turns }; + }), + rollbackThread: (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: DROID_PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterValidationError({ + provider: DROID_PROVIDER, + operation: "rollbackThread", + issue: + "Droid rollback is not supported until T3 turns can be mapped to Droid rewind message IDs.", + }); + }), + stopAll: () => + Effect.forEach([...sessions.keys()], stopSession, { + concurrency: "unbounded", + discard: true, + }), + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies DroidAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/DroidProvider.test.ts b/apps/server/src/provider/Layers/DroidProvider.test.ts new file mode 100644 index 00000000000..6486d7ba0b5 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.test.ts @@ -0,0 +1,76 @@ +import { ModelProvider, ReasoningEffort, type AvailableModelConfig } from "@factory/droid-sdk"; +import { DroidSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vitest"; + +import { buildDroidModelsFromSdkModels, makePendingDroidProvider } from "./DroidProvider.ts"; + +const sdkModel = (model: AvailableModelConfig): AvailableModelConfig => model; +const decodeDroidSettings = Schema.decodeSync(DroidSettings); + +describe("DroidProvider", () => { + it("reports disabled pending provider status when Droid is disabled", async () => { + const settings = decodeDroidSettings({ + enabled: false, + binaryPath: "fake-droid", + }); + const provider = await Effect.runPromise(makePendingDroidProvider(settings)); + + expect(provider.enabled).toBe(false); + expect(provider.status).toBe("disabled"); + expect(provider.installed).toBe(false); + expect(provider.message).toBe("Droid is disabled in T3 Code settings."); + }); + + it("maps Droid SDK built-in and custom models into provider models", () => { + const models = buildDroidModelsFromSdkModels([ + sdkModel({ + id: "glm-5.1", + modelId: "glm-5.1", + displayName: "Droid Core (GLM-5.1)", + shortDisplayName: "GLM-5.1", + modelProvider: ModelProvider.FACTORY, + supportedReasoningEfforts: [ReasoningEffort.Off, ReasoningEffort.High], + defaultReasoningEffort: ReasoningEffort.High, + isCustom: false, + }), + sdkModel({ + id: "custom:HomeLab-GLM-5.1-26", + modelId: "glm-5.1", + displayName: "HomeLab - GLM-5.1", + shortDisplayName: "HomeLab GLM", + modelProvider: ModelProvider.GENERIC_CHAT_COMPLETION_API, + supportedReasoningEfforts: [ReasoningEffort.None], + defaultReasoningEffort: ReasoningEffort.None, + isCustom: true, + }), + sdkModel({ + id: "custom:Proxy-GLM-5.1-27", + modelId: "glm-5.1", + displayName: "Proxy - GLM-5.1", + shortDisplayName: "Proxy GLM", + modelProvider: ModelProvider.GENERIC_CHAT_COMPLETION_API, + supportedReasoningEfforts: [ReasoningEffort.None], + defaultReasoningEffort: ReasoningEffort.None, + isCustom: true, + }), + ]); + + expect(models.map((model) => [model.slug, model.name, model.isCustom])).toEqual([ + ["glm-5.1", "Droid Core (GLM-5.1)", false], + ["custom:HomeLab-GLM-5.1-26", "HomeLab - GLM-5.1", true], + ["custom:Proxy-GLM-5.1-27", "Proxy - GLM-5.1", true], + ]); + expect(models[0]?.subProvider).toBe("Factory"); + expect(models[1]?.subProvider).toBe("Custom"); + expect(models[0]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + currentValue: "high", + options: [ + { id: "off", label: "Off" }, + { id: "high", label: "High", isDefault: true }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts new file mode 100644 index 00000000000..f9045bf9304 --- /dev/null +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -0,0 +1,347 @@ +import { + type AvailableModelConfig, + createSession, + type CreateSessionOptions, + type DroidSession, + ModelProvider, + ReasoningEffort, +} from "@factory/droid-sdk"; +import { tmpdir } from "node:os"; +import { + type DroidSettings, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("droid"); +const DROID_PRESENTATION = { + displayName: "Droid", + badgeLabel: "WIP", + showInteractionModeToggle: true, +} as const; +const DROID_CLI_TIMEOUT_MS = 10_000; +const DROID_MODEL_DISCOVERY_TIMEOUT_MS = 20_000; + +const REASONING_EFFORT_LABELS: Readonly> = { + [ReasoningEffort.None]: "None", + [ReasoningEffort.Dynamic]: "Dynamic", + [ReasoningEffort.Off]: "Off", + [ReasoningEffort.Minimal]: "Minimal", + [ReasoningEffort.Low]: "Low", + [ReasoningEffort.Medium]: "Medium", + [ReasoningEffort.High]: "High", + [ReasoningEffort.ExtraHigh]: "Extra High", + [ReasoningEffort.Max]: "Max", +}; + +const DROID_FALLBACK_MODEL_CAPABILITIES = createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + }), + ], +}); + +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: "default", + name: "Factory default", + shortName: "Default", + isCustom: false, + capabilities: DROID_FALLBACK_MODEL_CAPABILITIES, + }, +]; + +interface DroidProviderSdk { + readonly createSession: (options?: CreateSessionOptions) => Promise; +} + +interface DroidProviderStatusOptions { + readonly sdk?: DroidProviderSdk; +} + +class DroidModelDiscoveryError extends Data.TaggedError("DroidModelDiscoveryError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const defaultSdk: DroidProviderSdk = { createSession }; + +const modelProviderLabel = (provider: ModelProvider): string => { + switch (provider) { + case ModelProvider.ANTHROPIC: + return "Anthropic"; + case ModelProvider.OPENAI: + return "OpenAI"; + case ModelProvider.GENERIC_CHAT_COMPLETION_API: + return "Custom"; + case ModelProvider.FACTORY: + return "Factory"; + case ModelProvider.GOOGLE: + return "Google"; + case ModelProvider.XAI: + return "xAI"; + case ModelProvider.VOYAGE: + return "Voyage"; + } +}; + +const compactEnvironment = (environment: NodeJS.ProcessEnv): Record => { + const env: Record = {}; + for (const [key, value] of Object.entries(environment)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; +}; + +function droidModelCapabilities(model: AvailableModelConfig) { + const options = model.supportedReasoningEfforts.map((effort) => ({ + value: effort, + label: REASONING_EFFORT_LABELS[effort] ?? effort, + isDefault: effort === model.defaultReasoningEffort, + })); + return createModelCapabilities({ + optionDescriptors: + options.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options, + }), + ] + : [], + }); +} + +function droidAvailableModelToServerModel(model: AvailableModelConfig): ServerProviderModel | null { + const slug = model.isCustom + ? (nonEmpty(model.id) ?? nonEmpty(model.modelId)) + : (nonEmpty(model.modelId) ?? nonEmpty(model.id)); + if (!slug) return null; + const name = nonEmpty(model.displayName) ?? slug; + const shortName = nonEmpty(model.shortDisplayName); + return { + slug, + name, + ...(shortName && shortName !== name ? { shortName } : {}), + subProvider: modelProviderLabel(model.modelProvider), + isCustom: model.isCustom, + capabilities: droidModelCapabilities(model), + }; +} + +const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; + +export function buildDroidModelsFromSdkModels( + models: ReadonlyArray | undefined, +): ReadonlyArray { + const seen = new Set(); + const resolved: ServerProviderModel[] = []; + for (const model of models ?? []) { + const entry = droidAvailableModelToServerModel(model); + if (!entry || seen.has(entry.slug)) { + continue; + } + seen.add(entry.slug); + resolved.push(entry); + } + return resolved; +} + +const discoverDroidModels = ( + settings: DroidSettings, + environment: NodeJS.ProcessEnv, + options?: DroidProviderStatusOptions, +): Effect.Effect, DroidModelDiscoveryError> => + Effect.tryPromise({ + try: async (abortSignal) => { + let session: DroidSession | undefined; + try { + session = await (options?.sdk ?? defaultSdk).createSession({ + cwd: tmpdir(), + execPath: settings.binaryPath, + env: compactEnvironment(environment), + abortSignal, + }); + return buildDroidModelsFromSdkModels(session.initResult.availableModels); + } finally { + await session?.close().catch(() => undefined); + } + }, + catch: (cause) => + new DroidModelDiscoveryError({ + message: cause instanceof Error ? cause.message : "Failed to discover Droid models.", + cause, + }), + }); + +const modelsWithSettingsFallback = ( + sdkModels: ReadonlyArray, + settings: DroidSettings, +): ReadonlyArray => + providerModelsFromSettings( + sdkModels.length > 0 ? sdkModels : FALLBACK_MODELS, + PROVIDER, + settings.customModels, + DROID_FALLBACK_MODEL_CAPABILITIES, + ); + +export function makePendingDroidProvider( + settings: DroidSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = modelsWithSettingsFallback([], settings); + + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: { + installed: settings.enabled, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Droid availability..." + : "Droid is disabled in T3 Code settings.", + }, + }); + }); +} + +export function checkDroidProviderStatus( + settings: DroidSettings, + environment: NodeJS.ProcessEnv, + options?: DroidProviderStatusOptions, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const fallbackModels = modelsWithSettingsFallback([], settings); + + if (!settings.enabled) { + return yield* makePendingDroidProvider(settings); + } + + const command = ChildProcess.make(settings.binaryPath, ["--version"], { + env: environment, + shell: process.platform === "win32", + }); + const result = yield* spawnAndCollect(settings.binaryPath, command).pipe( + Effect.timeoutOption(DROID_CLI_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(result)) { + const cause = result.failure; + const message = cause instanceof Error ? cause.message : String(cause); + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause({ message }), + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isCommandMissingCause({ message }) + ? "Droid CLI (`droid`) is not installed or not on PATH." + : `Failed to execute Droid CLI health check: ${message}.`, + }, + }); + } + + if (Option.isNone(result.success)) { + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Timed out while checking Droid CLI availability.", + }, + }); + } + + const commandResult = result.success.value; + const detail = detailFromResult(commandResult); + const missing = detail ? isCommandMissingCause({ message: detail }) : false; + const discoveredModels = + commandResult.code === 0 + ? yield* discoverDroidModels(settings, environment, options).pipe( + Effect.timeoutOption(DROID_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ) + : Result.succeed(Option.none>()); + const modelDiscoveryFailed = + commandResult.code === 0 && + (Result.isFailure(discoveredModels) || Option.isNone(discoveredModels.success)); + const discoveryMessage = Result.isFailure(discoveredModels) + ? discoveredModels.failure.message + : modelDiscoveryFailed + ? "Timed out while discovering Droid models." + : undefined; + const models = + Result.isSuccess(discoveredModels) && Option.isSome(discoveredModels.success) + ? modelsWithSettingsFallback(discoveredModels.success.value, settings) + : fallbackModels; + return buildServerProvider({ + presentation: DROID_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: commandResult.code === 0 || !missing, + version: parseGenericCliVersion(commandResult.stdout || commandResult.stderr), + status: commandResult.code === 0 && !modelDiscoveryFailed ? "ready" : "warning", + auth: { status: commandResult.code === 0 ? "unknown" : "unauthenticated" }, + ...(commandResult.code === 0 && discoveryMessage + ? { message: `Droid model discovery failed: ${discoveryMessage}` } + : commandResult.code === 0 + ? {} + : { + message: missing + ? "Droid CLI (`droid`) is not installed or not on PATH." + : (detail ?? "Failed to check Droid CLI availability."), + }), + }, + }); + }); +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..52465fdc386 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1300,6 +1300,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "claudeAgent", "codex", "cursor", + "droid", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..3c5dce31913 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { DroidDriver, type DroidDriverEnv } from "./Drivers/DroidDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DroidDriverEnv | OpenCodeDriverEnv; /** @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray void; +} + +export interface PendingDroidUserInput { + readonly questions: ReadonlyArray; + readonly droidQuestions: AskUserRequestParams["questions"]; + readonly resolve: (result: AskUserResult) => void; +} + +export interface DroidContext { + session: ProviderSession; + readonly droid: DroidSession; + readonly pendingPermissions: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeAbort: AbortController | undefined; + activeAssistantItems: Map; + activeThinkingItems: Map; + activeCompletedAssistantItems: Set; + activeTurnError: string | undefined; + activeTokenUsage: ThreadTokenUsageSnapshot | undefined; + activeTokenUsageBaseline: ThreadTokenUsageSnapshot | undefined; + cumulativeTokenUsage: ThreadTokenUsageSnapshot | undefined; +} + +export interface DroidAdapterOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly sdk?: { + readonly createSession: (options?: CreateSessionOptions) => Promise; + readonly resumeSession: ( + sessionId: string, + options?: ResumeSessionOptions, + ) => Promise; + }; +} + +export type DroidAdapterShape = ProviderAdapterShape; diff --git a/apps/server/src/provider/droid/DroidAttachmentResolver.ts b/apps/server/src/provider/droid/DroidAttachmentResolver.ts new file mode 100644 index 00000000000..29bcf83f6b6 --- /dev/null +++ b/apps/server/src/provider/droid/DroidAttachmentResolver.ts @@ -0,0 +1,72 @@ +import type { Base64ImageSource } from "@factory/droid-sdk"; +import type * as FileSystem from "effect/FileSystem"; +import * as Effect from "effect/Effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { DROID_PROVIDER, type DroidAdapterShape } from "./DroidAdapterTypes.ts"; + +const SUPPORTED_DROID_IMAGE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; + +type SupportedDroidImageMimeType = (typeof SUPPORTED_DROID_IMAGE_MIME_TYPES)[number]; + +const isSupportedDroidImageMimeType = (value: string): value is SupportedDroidImageMimeType => + (SUPPORTED_DROID_IMAGE_MIME_TYPES as ReadonlyArray).includes(value); + +type DroidAttachments = NonNullable[0]["attachments"]>; + +export function resolveDroidImages( + attachments: DroidAttachments, + dependencies: { + readonly attachmentsDir: string; + readonly fileSystem: FileSystem.FileSystem; + }, +) { + const { attachmentsDir, fileSystem } = dependencies; + return Effect.forEach( + attachments, + (attachment) => + Effect.gen(function* () { + if (!isSupportedDroidImageMimeType(attachment.mimeType)) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Unsupported Droid image attachment type '${attachment.mimeType}'.`, + }); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: DROID_PROVIDER, + method: "turn/start", + detail: `Failed to read attachment file: ${cause.message}.`, + cause, + }), + ), + ); + return { + type: "base64", + data: Buffer.from(bytes).toString("base64"), + mediaType: attachment.mimeType, + } satisfies Base64ImageSource; + }), + { concurrency: 1 }, + ); +} diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts new file mode 100644 index 00000000000..fb9f32358fb --- /dev/null +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { DroidMessageType, type DroidMessage } from "@factory/droid-sdk"; +import { + EventId, + RuntimeItemId, + RuntimeRequestId, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type RuntimeContentStreamKind, + type TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { DROID_PROVIDER, type DroidContext } from "./DroidAdapterTypes.ts"; +import { contentBlockText, toTokenUsageSnapshot, toToolItemType } from "./DroidSdkMappings.ts"; + +export const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe()); + +export function updateDroidContextSession( + context: DroidContext, + patch: Partial, +) { + context.session = { + ...context.session, + ...patch, + updatedAt: nowIso(), + }; +} + +export function makeDroidEventBase(instanceId: ProviderInstanceId) { + return ( + context: DroidContext, + input?: { + turnId?: TurnId; + itemId?: string; + requestId?: string; + raw?: unknown; + }, + ) => ({ + eventId: EventId.make(randomUUID()), + provider: DROID_PROVIDER, + providerInstanceId: instanceId, + threadId: context.session.threadId, + createdAt: nowIso(), + ...(input?.turnId ? { turnId: input.turnId } : {}), + ...(input?.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input?.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input?.raw !== undefined + ? { raw: { source: "droid.sdk.message" as const, payload: input.raw } } + : {}), + }); +} + +type DroidEventBase = ReturnType; + +export async function handleDroidMessage(input: { + readonly context: DroidContext; + readonly turnId: TurnId; + readonly message: DroidMessage; + readonly eventBase: DroidEventBase; + readonly emitNow: (event: ProviderRuntimeEvent) => Promise; +}) { + const { context, turnId, message, eventBase, emitNow } = input; + const base = (itemId?: string) => + eventBase(context, { turnId, raw: message, ...(itemId ? { itemId } : {}) }); + + switch (message.type) { + case DroidMessageType.AssistantTextDelta: + case DroidMessageType.ThinkingTextDelta: { + const itemId = `${message.messageId}-${message.blockIndex}`; + const streamKind: RuntimeContentStreamKind = + message.type === DroidMessageType.AssistantTextDelta ? "assistant_text" : "reasoning_text"; + const activeItems = + streamKind === "assistant_text" + ? context.activeAssistantItems + : context.activeThinkingItems; + activeItems.set(itemId, `${activeItems.get(itemId) ?? ""}${message.text}`); + return emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind, delta: message.text }, + }); + } + case DroidMessageType.CreateMessage: { + if (message.role !== "assistant") { + return; + } + for (const [index, block] of message.content.entries()) { + const text = contentBlockText(block); + if (text.length === 0) { + continue; + } + const itemId = `${message.messageId}-${index}`; + if (block.type === "text") { + const previousText = context.activeAssistantItems.get(itemId) ?? ""; + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta.length > 0) { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "assistant_text", delta }, + }); + } + context.activeAssistantItems.set(itemId, text); + continue; + } + if (block.type === "thinking") { + const previousText = context.activeThinkingItems.get(itemId) ?? ""; + const delta = text.startsWith(previousText) ? text.slice(previousText.length) : text; + if (delta.length > 0) { + await emitNow({ + ...base(itemId), + type: "content.delta", + payload: { streamKind: "reasoning_text", delta }, + }); + } + context.activeThinkingItems.set(itemId, text); + } + } + + const firstTextIndex = message.content.findIndex((block) => block.type === "text"); + const firstTextBlock = message.content[firstTextIndex]; + const completedItemId = + firstTextIndex >= 0 ? `${message.messageId}-${firstTextIndex}` : message.messageId; + if (!context.activeCompletedAssistantItems.has(completedItemId)) { + context.activeCompletedAssistantItems.add(completedItemId); + return emitNow({ + ...base(completedItemId), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + ...(firstTextBlock ? { detail: contentBlockText(firstTextBlock) } : {}), + }, + }); + } + return; + } + case DroidMessageType.ToolUse: + return emitNow({ + ...base(message.toolUseId), + type: "item.started", + payload: { + itemType: toToolItemType(message.toolName), + status: "inProgress", + title: message.toolName, + data: message.toolInput, + }, + }); + case DroidMessageType.ToolProgress: + return emitNow({ + ...base(message.toolUseId), + type: "item.updated", + payload: { + itemType: toToolItemType(message.toolName), + status: "inProgress", + title: message.toolName, + detail: message.content, + data: message.update, + }, + }); + case DroidMessageType.ToolResult: + return emitNow({ + ...base(message.toolUseId), + type: "item.completed", + payload: { + itemType: toToolItemType(message.toolName), + status: message.isError ? "failed" : "completed", + title: message.toolName, + detail: + typeof message.content === "string" ? message.content : JSON.stringify(message.content), + }, + }); + case DroidMessageType.WorkingStateChanged: + return emitNow({ + ...base(), + type: "session.state.changed", + payload: { + state: + message.state === "idle" + ? "ready" + : message.state.includes("waiting") + ? "waiting" + : "running", + detail: message, + }, + }); + case DroidMessageType.TokenUsageUpdate: + context.activeTokenUsage = toTokenUsageSnapshot(message, context.activeTokenUsageBaseline); + context.cumulativeTokenUsage = context.activeTokenUsage; + return emitNow({ + ...base(), + type: "thread.token-usage.updated", + payload: { usage: context.activeTokenUsage }, + }); + case DroidMessageType.SessionTitleUpdated: + return emitNow({ + ...base(), + type: "thread.metadata.updated", + payload: { name: message.title }, + }); + case DroidMessageType.SettingsUpdated: + return emitNow({ + ...base(), + type: "session.configured", + payload: { config: message.settings }, + }); + case DroidMessageType.McpStatusChanged: + return emitNow({ + ...base(), + type: "mcp.status.updated", + payload: { status: message }, + }); + case DroidMessageType.McpAuthRequired: + return emitNow({ + ...base(), + type: "auth.status", + payload: { isAuthenticating: true, output: [message.message] }, + }); + case DroidMessageType.McpAuthCompleted: + return emitNow({ + ...base(), + type: "mcp.oauth.completed", + payload: { + success: message.outcome === "success", + name: message.serverName, + ...(message.outcome === "success" ? {} : { error: message.message }), + }, + }); + case DroidMessageType.Error: + context.activeTurnError = message.message; + return emitNow({ + ...base(), + type: "runtime.error", + payload: { message: message.message, class: "provider_error" }, + }); + case DroidMessageType.TurnComplete: + if (message.tokenUsage) { + context.activeTokenUsage = toTokenUsageSnapshot( + message.tokenUsage, + context.activeTokenUsageBaseline, + ); + } + context.cumulativeTokenUsage = context.activeTokenUsage ?? context.cumulativeTokenUsage; + return; + default: + return; + } +} diff --git a/apps/server/src/provider/droid/DroidSdkMappings.ts b/apps/server/src/provider/droid/DroidSdkMappings.ts new file mode 100644 index 00000000000..54be9342985 --- /dev/null +++ b/apps/server/src/provider/droid/DroidSdkMappings.ts @@ -0,0 +1,197 @@ +import { + AutonomyLevel, + type AskUserRequestParams, + type AskUserResult, + type ContentBlock, + DroidInteractionMode, + ReasoningEffort, + ToolConfirmationOutcome, + ToolConfirmationType, + type RequestPermissionRequestParams, + type TokenUsageUpdate, +} from "@factory/droid-sdk"; +import type { + CanonicalRequestType, + ProviderApprovalDecision, + ProviderSessionStartInput, + ProviderUserInputAnswers, + ThreadTokenUsageSnapshot, + ToolLifecycleItemType, + UserInputQuestion, +} from "@t3tools/contracts"; + +export { DroidInteractionMode }; + +export function toModelId(model: string | undefined): string | undefined { + return !model || model === "default" ? undefined : model; +} + +export function toReasoningEffort(value: string | undefined): ReasoningEffort | undefined { + switch (value) { + case "none": + return ReasoningEffort.None; + case "dynamic": + return ReasoningEffort.Dynamic; + case "off": + return ReasoningEffort.Off; + case "minimal": + return ReasoningEffort.Minimal; + case "low": + return ReasoningEffort.Low; + case "medium": + return ReasoningEffort.Medium; + case "high": + return ReasoningEffort.High; + case "xhigh": + return ReasoningEffort.ExtraHigh; + case "max": + return ReasoningEffort.Max; + default: + return undefined; + } +} + +export function toAutonomyLevel(input: ProviderSessionStartInput): AutonomyLevel { + switch (input.runtimeMode) { + case "approval-required": + return AutonomyLevel.Off; + case "auto-accept-edits": + return AutonomyLevel.Low; + case "medium-access": + return AutonomyLevel.Medium; + case "full-access": + return AutonomyLevel.High; + } +} + +export function contentBlockText(block: ContentBlock): string { + if (block.type === "text") return block.text; + if (block.type === "thinking") return block.thinking; + return ""; +} + +export function toRequestType(params: RequestPermissionRequestParams): CanonicalRequestType { + const type = params.toolUses[0]?.confirmationType; + switch (type) { + case ToolConfirmationType.Execute: + return "command_execution_approval"; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "file_change_approval"; + case ToolConfirmationType.McpTool: + return "dynamic_tool_call"; + case ToolConfirmationType.AskUser: + return "tool_user_input"; + default: + return "unknown"; + } +} + +export function toToolItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("exec") || + normalized.includes("bash") || + normalized.includes("command") + ) { + return "command_execution"; + } + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("web")) return "web_search"; + if (normalized.includes("image")) return "image_view"; + return "dynamic_tool_call"; +} + +export function permissionDetail(params: RequestPermissionRequestParams): string { + const first = params.toolUses[0]; + if (!first) return "Droid requested permission."; + const details = first.details; + switch (details.type) { + case ToolConfirmationType.Execute: + return details.fullCommand; + case ToolConfirmationType.Edit: + case ToolConfirmationType.Create: + case ToolConfirmationType.ApplyPatch: + return "filePath" in details ? details.filePath : "Droid requested a file change."; + case ToolConfirmationType.McpTool: + return details.toolName; + default: + return first.toolUse.name; + } +} + +export function normalizeAskUserQuestions( + params: AskUserRequestParams, +): ReadonlyArray { + return params.questions.map((question, index) => ({ + id: `question-${question.index ?? index}`, + header: question.topic || `Question ${index + 1}`, + question: question.question, + options: question.options.map((option) => ({ + label: option, + description: option, + })), + })); +} + +function answerString(value: unknown): string { + if (Array.isArray(value)) return value.map(answerString).join(", "); + return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value); +} + +export function toAskUserResult( + questions: AskUserRequestParams["questions"], + answers: ProviderUserInputAnswers, +): AskUserResult { + return { + answers: questions.map((question, index) => ({ + index: question.index, + question: question.question, + answer: answerString( + answers[`question-${question.index ?? index}`] ?? answers[question.question], + ), + })), + }; +} + +export function toOutcome(decision: ProviderApprovalDecision): ToolConfirmationOutcome { + switch (decision) { + case "accept": + return ToolConfirmationOutcome.ProceedOnce; + case "acceptForSession": + return ToolConfirmationOutcome.ProceedAlways; + case "decline": + case "cancel": + return ToolConfirmationOutcome.Cancel; + } +} + +export function toTokenUsageSnapshot( + usage: TokenUsageUpdate, + previous?: ThreadTokenUsageSnapshot, +): ThreadTokenUsageSnapshot { + const lastInputTokens = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens; + const lastOutputTokens = usage.outputTokens + usage.thinkingTokens; + const lastCachedInputTokens = usage.cacheReadTokens; + const lastReasoningOutputTokens = usage.thinkingTokens; + const inputTokens = (previous?.inputTokens ?? 0) + lastInputTokens; + const cachedInputTokens = (previous?.cachedInputTokens ?? 0) + lastCachedInputTokens; + const outputTokens = (previous?.outputTokens ?? 0) + lastOutputTokens; + const reasoningOutputTokens = (previous?.reasoningOutputTokens ?? 0) + lastReasoningOutputTokens; + return { + usedTokens: inputTokens + outputTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + lastUsedTokens: lastInputTokens + lastOutputTokens, + lastInputTokens, + lastCachedInputTokens, + lastOutputTokens, + lastReasoningOutputTokens, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..e817f748772 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -147,6 +147,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { normalizeRuntimeModeForProvider } from "./chat/runtimeModePresentation"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -807,7 +808,7 @@ export default function ChatView(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const rawRuntimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; @@ -1262,6 +1263,23 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const runtimeMode = serverConfig + ? normalizeRuntimeModeForProvider(selectedProvider, rawRuntimeMode) + : rawRuntimeMode; + useEffect(() => { + if (runtimeMode === rawRuntimeMode) return; + setComposerDraftRuntimeMode(composerDraftTarget, runtimeMode); + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { runtimeMode }); + } + }, [ + composerDraftTarget, + isLocalDraftThread, + rawRuntimeMode, + runtimeMode, + setComposerDraftRuntimeMode, + setDraftThreadContext, + ]); const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b3211e17753..597fd8e2029 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -651,6 +651,15 @@ export const OpenCodeIcon: Icon = (props) => ( ); +export const DroidIcon: Icon = (props) => ( + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( = { - "approval-required": { - label: "Supervised", - description: "Ask before commands and file changes.", - icon: LockIcon, - }, - "auto-accept-edits": { - label: "Auto-accept edits", - description: "Auto-approve edits, ask before other actions.", - icon: PenLineIcon, - }, - "full-access": { - label: "Full access", - description: "Allow commands and edits without prompts.", - icon: LockOpenIcon, - }, -}; - -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_FLOATING_LAYER_SELECTOR = [ @@ -181,6 +151,7 @@ function isInsideComposerFloatingLayer(element: Element): boolean { const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; + provider: ProviderDriverKind; runtimeMode: RuntimeMode; showPlanToggle: boolean; planSidebarLabel: string; @@ -189,6 +160,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -2343,6 +2316,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode={interactionMode} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + provider={selectedProvider} runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} @@ -2361,6 +2335,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { interactionMode="default" planSidebarLabel="Plan" planSidebarOpen={false} + provider={ProviderDriverKind.make("claudeAgent")} runtimeMode="approval-required" showInteractionModeToggle={false} onToggleInteractionMode={vi.fn()} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..4348378d67a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,4 +1,4 @@ -import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; +import type { ProviderDriverKind, ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; @@ -11,12 +11,14 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { getRuntimeModeConfig, getRuntimeModeOptions } from "./runtimeModePresentation"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; interactionMode: ProviderInteractionMode; planSidebarLabel: string; planSidebarOpen: boolean; + provider: ProviderDriverKind; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; @@ -24,6 +26,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const runtimeModeConfig = getRuntimeModeConfig(props.provider); + const runtimeModeOptions = getRuntimeModeOptions(props.provider); + return ( - Supervised - Auto-accept edits - Full access + {runtimeModeOptions.map((mode) => ( + + {runtimeModeConfig[mode].label} + + ))} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 12103194870..d3b74a4693c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -131,7 +131,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-fade="true"'); expect(markup).toContain('data-user-message-footer="true"'); - }); + }, 10_000); it("does not render collapse controls for short user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f36..88b64463cd7 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DroidIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("droid")]: DroidIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/chat/runtimeModePresentation.test.ts b/apps/web/src/components/chat/runtimeModePresentation.test.ts new file mode 100644 index 00000000000..e3a613f6c54 --- /dev/null +++ b/apps/web/src/components/chat/runtimeModePresentation.test.ts @@ -0,0 +1,23 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { getRuntimeModeOptions, normalizeRuntimeModeForProvider } from "./runtimeModePresentation"; + +describe("runtimeModePresentation", () => { + it("keeps medium access available only for Droid", () => { + expect(getRuntimeModeOptions(ProviderDriverKind.make("droid"))).toContain("medium-access"); + expect(getRuntimeModeOptions(ProviderDriverKind.make("codex"))).not.toContain("medium-access"); + }); + + it("normalizes Droid-only medium access when switching to another provider", () => { + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("codex"), "medium-access")).toBe( + "auto-accept-edits", + ); + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("droid"), "medium-access")).toBe( + "medium-access", + ); + expect(normalizeRuntimeModeForProvider(ProviderDriverKind.make("codex"), "full-access")).toBe( + "full-access", + ); + }); +}); diff --git a/apps/web/src/components/chat/runtimeModePresentation.ts b/apps/web/src/components/chat/runtimeModePresentation.ts new file mode 100644 index 00000000000..283e1d08df8 --- /dev/null +++ b/apps/web/src/components/chat/runtimeModePresentation.ts @@ -0,0 +1,87 @@ +import { ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; +import { LockIcon, LockOpenIcon, type LucideIcon, PenLineIcon, ShieldIcon } from "lucide-react"; + +export interface RuntimeModePresentation { + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; +} + +const BASE_RUNTIME_MODE_CONFIG: Record = { + "approval-required": { + label: "Supervised", + description: "Ask before commands and file changes.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Auto-accept edits", + description: "Auto-approve edits, ask before other actions.", + icon: PenLineIcon, + }, + "medium-access": { + label: "Medium access", + description: "Allow reversible commands, ask before riskier actions.", + icon: ShieldIcon, + }, + "full-access": { + label: "Full access", + description: "Allow commands and edits without prompts.", + icon: LockOpenIcon, + }, +}; + +const DROID_RUNTIME_MODE_CONFIG: Record = { + "approval-required": { + label: "Off", + description: "Droid asks before every action.", + icon: LockIcon, + }, + "auto-accept-edits": { + label: "Low", + description: "Allow file edits and read-only commands.", + icon: PenLineIcon, + }, + "medium-access": { + label: "Medium", + description: "Allow reversible commands.", + icon: ShieldIcon, + }, + "full-access": { + label: "High", + description: "Allow all Droid actions without prompts.", + icon: LockOpenIcon, + }, +}; + +const BASE_RUNTIME_MODE_OPTIONS: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "full-access", +]; +const DROID_RUNTIME_MODE_OPTIONS: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "medium-access", + "full-access", +]; + +export function getRuntimeModeConfig( + provider: ProviderDriverKind, +): Record { + return provider === ProviderDriverKind.make("droid") + ? DROID_RUNTIME_MODE_CONFIG + : BASE_RUNTIME_MODE_CONFIG; +} + +export function getRuntimeModeOptions(provider: ProviderDriverKind): ReadonlyArray { + return provider === ProviderDriverKind.make("droid") + ? DROID_RUNTIME_MODE_OPTIONS + : BASE_RUNTIME_MODE_OPTIONS; +} + +export function normalizeRuntimeModeForProvider( + provider: ProviderDriverKind, + runtimeMode: RuntimeMode, +): RuntimeMode { + return getRuntimeModeOptions(provider).includes(runtimeMode) ? runtimeMode : "auto-accept-edits"; +} diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..84a97b314d1 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DroidSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DroidIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -59,6 +60,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + icon: DroidIcon, + badgeLabel: "WIP", + settingsSchema: DroidSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..b3fc8a44dbe 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("droid"), + label: "Droid", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/bun.lock b/bun.lock index ffc4a5922bd..911aba5512a 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@factory/droid-sdk": "^0.2.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", @@ -474,6 +475,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "@factory/droid-sdk": ["@factory/droid-sdk@0.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "uuid": "^11.1.0", "zod": "^3.24.0" } }, "sha512-m8Srp98pTvu5jAZtZpX6/Ojut6KV3CiqUGh0MXBUcsuKzsDw8hODZEbPTocxP6MrT0rsoKpqCS9SRTt0m+9cqw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -2152,6 +2155,10 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@factory/droid-sdk/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + + "@factory/droid-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..2d965b94e16 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const DROID_DRIVER_KIND = ProviderDriverKind.make("droid"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [DROID_DRIVER_KIND]: "Droid", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 401928171c8..2e1a3407833 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -117,6 +117,7 @@ export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals([ "approval-required", "auto-accept-edits", + "medium-access", "full-access", ]); export type RuntimeMode = typeof RuntimeMode.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..9ba336f78c8 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), + Schema.Literal("droid.sdk.message"), + Schema.Literal("droid.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..271181ee26f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,30 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DroidSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("droid").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Droid CLI used by the TypeScript SDK.", + providerSettingsForm: { placeholder: "droid", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DroidSettings = typeof DroidSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + droid: DroidSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -445,6 +470,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DroidSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + droid: Schema.optionalKey(DroidSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),