feat(droid): add Factory Droid as a provider - #7993
Conversation
Native stream-jsonrpc integration: the server drives `droid exec` over NDJSON JSON-RPC, with envelope-level session identity guarding rewind, compaction, and spec-handoff successors. Plan mode, MCP bridge, HITL approvals (including a new canonical plan_approval), subagent task lifecycle, rollback, resume, and text generation all wired across web, mobile, and docs. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| initialized.kind === "loaded" ? initialized.result.lastCallTokenUsage : undefined, | ||
| currentModelId: requestedModelId, | ||
| currentReasoningEffort: requestedEffort, | ||
| currentInteractionMode: "auto", |
There was a problem hiding this comment.
🟡 Medium Layers/DroidAdapter.ts:1237
Resuming a session persisted in spec mode executes the first normal turn in spec mode and returns plan behavior instead of acting normally. currentInteractionMode is always initialized to "auto", so sendTurn treats the requested auto mode as already applied and skips droid.update_session_settings; initialize it from the loaded session setting or explicitly reset loaded sessions to auto.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 1237:
Resuming a session persisted in `spec` mode executes the first normal turn in `spec` mode and returns plan behavior instead of acting normally. `currentInteractionMode` is always initialized to `"auto"`, so `sendTurn` treats the requested `auto` mode as already applied and skips `droid.update_session_settings`; initialize it from the loaded session setting or explicitly reset loaded sessions to `auto`.
| const isSpecSuccessor = | ||
| ctx.activeTurnId !== undefined && | ||
| ctx.currentInteractionMode === "spec" && | ||
| (ctx.specSuccessorSessionId === undefined || |
There was a problem hiding this comment.
🟠 High Layers/DroidAdapter.ts:1260
During an active spec-mode turn, the first foreign sessionId is accepted as specSuccessorSessionId, so a delayed abandoned-session or child notification can claim the successor and have its events applied to the live turn. The actual implementation successor is then rejected because its id differs, corrupting the handoff stream and preventing successor adoption. Associate the successor using a protocol-grounded identifier rather than accepting the first foreign envelope.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 1260:
During an active spec-mode turn, the first foreign `sessionId` is accepted as `specSuccessorSessionId`, so a delayed abandoned-session or child notification can claim the successor and have its events applied to the live turn. The actual implementation successor is then rejected because its id differs, corrupting the handoff stream and preventing successor adoption. Associate the successor using a protocol-grounded identifier rather than accepting the first foreign envelope.
| const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| message: input.message, | ||
| attachments: input.attachments, | ||
| }); | ||
|
|
||
| const generated = yield* runDroidJson({ | ||
| operation: "generateBranchName", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| modelSelection: input.modelSelection, | ||
| }); | ||
|
|
||
| return { | ||
| branch: sanitizeBranchFragment(generated.branch), | ||
| }; | ||
| }); | ||
|
|
||
| const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = | ||
| Effect.fn("DroidTextGeneration.generateThreadTitle")(function* (input) { | ||
| const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| message: input.message, | ||
| previousTitle: input.previousTitle, | ||
| attachments: input.attachments, | ||
| }); |
There was a problem hiding this comment.
🟡 Medium textGeneration/DroidTextGeneration.ts:217
Droid-generated branch names and thread titles ignore configured branchInstructions and threadTitleInstructions, so repository/user naming policies are silently not applied. Both calls omit policy: input.policy, unlike the commit and PR generators; pass the policy through to each prompt builder.
const { prompt, outputSchema } = buildBranchNamePrompt({
message: input.message,
attachments: input.attachments,
+ policy: input.policy,
});
@@
const { prompt, outputSchema } = buildThreadTitlePrompt({
message: input.message,
previousTitle: input.previousTitle,
attachments: input.attachments,
+ policy: input.policy,
});🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/textGeneration/DroidTextGeneration.ts around lines 217-241:
Droid-generated branch names and thread titles ignore configured `branchInstructions` and `threadTitleInstructions`, so repository/user naming policies are silently not applied. Both calls omit `policy: input.policy`, unlike the commit and PR generators; pass the policy through to each prompt builder.
| }), | ||
| ), | ||
| Effect.andThen( | ||
| Effect.forEach(splitter.end(), handleLine, { |
There was a problem hiding this comment.
🟡 Medium droid/DroidRpcClient.ts:545
A valid final NDJSON message without a trailing newline is never parsed when stdout closes, so its response or notification is silently lost. splitter.end() is evaluated while the pipeline is built, before Stream.runForEach consumes chunks, leaving Effect.andThen with the initially empty tail; defer the call until the stream completes.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/droid/DroidRpcClient.ts around line 545:
A valid final NDJSON message without a trailing newline is never parsed when stdout closes, so its response or notification is silently lost. `splitter.end()` is evaluated while the pipeline is built, before `Stream.runForEach` consumes chunks, leaving `Effect.andThen` with the initially empty tail; defer the call until the stream completes.
| ? upsertSessionBinding({ ...live, providerInstanceId: instanceId }, threadId) | ||
| : Effect.void; | ||
| }), | ||
| Effect.catchCause((cause) => |
There was a problem hiding this comment.
🟡 Medium Layers/ProviderService.ts:355
rollbackConversation returns success even when persistSessionSnapshot fails, so a rollback successor cursor can be lost and the thread resumes from the pre-rollback cursor after a server restart. The helper’s Effect.catchCause suppresses both listSessions and directory.upsert failures; use a failure-propagating path for rollback (and preserve best-effort handling only for event-driven persistence).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 355:
`rollbackConversation` returns success even when `persistSessionSnapshot` fails, so a rollback successor cursor can be lost and the thread resumes from the pre-rollback cursor after a server restart. The helper’s `Effect.catchCause` suppresses both `listSessions` and `directory.upsert` failures; use a failure-propagating path for rollback (and preserve best-effort handling only for event-driven persistence).
| const entry = state.pending.get(requestId); | ||
| if (entry === undefined || entry._tag !== "Pending" || entry.deferred !== deferred) { | ||
| return state; |
There was a problem hiding this comment.
🟡 Medium droid/DroidRpcClient.ts:740
Timed-out requests remain in lifecycle.pending when Droid never sends a response, so repeated timeouts grow this map for the process lifetime and cause unbounded memory usage. The cleanup at ensuring only removes entries tagged Pending; it should also remove the matching TimedOut entry.
const entry = state.pending.get(requestId);
- if (entry === undefined || entry._tag !== "Pending" || entry.deferred !== deferred) {
+ if (entry === undefined || (entry._tag === "Pending" && entry.deferred !== deferred)) {
return state;
}🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/droid/DroidRpcClient.ts around lines 740-742:
Timed-out requests remain in `lifecycle.pending` when Droid never sends a response, so repeated timeouts grow this map for the process lifetime and cause unbounded memory usage. The cleanup at `ensuring` only removes entries tagged `Pending`; it should also remove the matching `TimedOut` entry.
| autonomyLevel, | ||
| ...(requestedModelId ? { modelId: requestedModelId } : {}), | ||
| ...(requestedEffort ? { reasoningEffort: requestedEffort } : {}), | ||
| }).pipe(Effect.ignore); |
There was a problem hiding this comment.
🟠 High Layers/DroidAdapter.ts:1194
On resume, a failed or timed-out droid.update_session_settings is ignored, so the adapter exposes the loaded session as ready even when its persisted autonomy level remains more permissive than input.runtimeMode (for example, high instead of approval-required). This lets the session continue without the expected approvals; let the error fail startSession rather than discarding it.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 1194:
On resume, a failed or timed-out `droid.update_session_settings` is ignored, so the adapter exposes the loaded session as ready even when its persisted autonomy level remains more permissive than `input.runtimeMode` (for example, `high` instead of approval-required). This lets the session continue without the expected approvals; let the error fail `startSession` rather than discarding it.
| return { | ||
| slug, | ||
| name: model.displayName?.trim() || slug, |
There was a problem hiding this comment.
🟡 Medium Layers/DroidProvider.ts:107
When live discovery succeeds, buildDroidDiscoveredModels returns claude-opus-5 without isDefault: true, so the discovered list replaces the built-ins and model selection falls back to the first API result, potentially selecting a different model than Droid's configured default. Preserve the built-in default designation when constructing discovered models.
return {
slug,
+ isDefault: DROID_BUILT_IN_MODELS.some((builtIn) => builtIn.slug === slug && builtIn.isDefault),
name: model.displayName?.trim() || slug,🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidProvider.ts around lines 107-109:
When live discovery succeeds, `buildDroidDiscoveredModels` returns `claude-opus-5` without `isDefault: true`, so the discovered list replaces the built-ins and model selection falls back to the first API result, potentially selecting a different model than Droid's configured default. Preserve the built-in default designation when constructing discovered models.
| : undefined; | ||
| if (role !== "user" && role !== "user_message") continue; | ||
| if (typeof message.id !== "string" || !message.id.trim()) continue; | ||
| turns.push({ id: TurnId.make(message.id), items: [] }); |
There was a problem hiding this comment.
🟡 Medium Layers/DroidAdapter.ts:332
After resuming a session that contains steering messages, ctx.turns contains one T3 turn for every persisted user message, so rollbackThread(..., 1) anchors on the final steer instead of the logical turn's opening message and rewinds only part of that turn. sendTurn coalesces steering into the existing turn, so droidLoadedTurns must apply the same grouping when rebuilding turns from durable messages.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 332:
After resuming a session that contains steering messages, `ctx.turns` contains one T3 turn for every persisted user message, so `rollbackThread(..., 1)` anchors on the final steer instead of the logical turn's opening message and rewinds only part of that turn. `sendTurn` coalesces steering into the existing turn, so `droidLoadedTurns` must apply the same grouping when rebuilding turns from durable messages.
There was a problem hiding this comment.
Two Effect-convention issues in the new Droid provider code. Everything else (dependency acquisition from the environment, no ManagedRuntime/runPromise in service construction, namespaced effect/* imports, existing ProviderAdapter*Error usage) looks consistent with the conventions.
Posted via Macroscope — Effect Service Conventions
| export class DroidRpcSpawnError extends Data.TaggedError("DroidRpcSpawnError")<{ | ||
| readonly command: string; | ||
| readonly cause: unknown; | ||
| }> {} | ||
|
|
||
| export class DroidRpcError extends Data.TaggedError("DroidRpcError")<{ | ||
| readonly kind: "encode" | "write" | "timeout" | "rpc" | "process-exit" | "duplicate-response"; | ||
| readonly message: string; | ||
| readonly method?: string; | ||
| readonly requestId?: string; | ||
| readonly code?: number; | ||
| readonly data?: unknown; | ||
| readonly cause?: unknown; | ||
| }> {} |
There was a problem hiding this comment.
These transport failures are declared with Data.TaggedError and carry a freeform message string built at each construction site, so the message is data rather than something derived from structured attributes. The convention is Schema.TaggedErrorClass with structured, serializable attributes and a message getter derived from them (packages/effect-acp/src/errors.ts is the in-repo precedent for the equivalent transport layer).
Suggested shape: keep kind as the discriminator (its values are already the operation taxonomy), promote the values currently interpolated into message into fields (e.g. timeoutMs for the timeout case), type cause as Schema.Defect(), and drop the stored message in favour of an override get message() that formats from kind/method/requestId. DroidRpcSpawnError should likewise use Schema.TaggedErrorClass with command: Schema.String and cause: Schema.Defect().
Posted via Macroscope — Effect Service Conventions
| Effect.catchTag("SchemaError", (cause) => | ||
| Effect.fail( | ||
| new ProviderAdapterRequestError({ | ||
| provider: PROVIDER, | ||
| method: "droid.execute_rewind", | ||
| detail: "Failed to decode Droid rewind result.", | ||
| cause, | ||
| }), | ||
| ), | ||
| ), |
There was a problem hiding this comment.
Statically known tagged failures should be recovered with Effect.catchTags({ ... }), including when only one tag is handled — Effect.catchTag is not the convention here.
| Effect.catchTag("SchemaError", (cause) => | |
| Effect.fail( | |
| new ProviderAdapterRequestError({ | |
| provider: PROVIDER, | |
| method: "droid.execute_rewind", | |
| detail: "Failed to decode Droid rewind result.", | |
| cause, | |
| }), | |
| ), | |
| ), | |
| Effect.catchTags({ | |
| SchemaError: (cause) => | |
| Effect.fail( | |
| new ProviderAdapterRequestError({ | |
| provider: PROVIDER, | |
| method: "droid.execute_rewind", | |
| detail: "Failed to decode Droid rewind result.", | |
| cause, | |
| }), | |
| ), | |
| }), |
Posted via Macroscope — Effect Service Conventions
There was a problem hiding this comment.
UI consistency review of the web-scope changes (apps/web/src/**). One finding on the new DroidIcon tone contract; the remaining web changes (provider option/definition entries, plan approval labels, model placeholder, diagnostics regex, display-name mapping) follow the existing patterns.
Posted via Macroscope — UI Consistency
| export const DroidIcon: Icon = (props) => ( | ||
| <svg {...props} viewBox="100 90 700 700" fill="currentColor"> |
There was a problem hiding this comment.
DroidIcon paints with fill="currentColor", so its tone comes from whatever text color the surrounding surface sets. Every other provider glyph registered in PROVIDER_ICON_BY_PROVIDER pins its own tone instead (OpenAI, CursorIcon, GrokIcon via cn("fill-… dark:fill-…", className), OpenCodeIcon via light/dark paths). In the muted surfaces that render provider glyphs — ThreadCommandSubtitle (text-muted-foreground/70 ancestor + opacity-70) and the sidebar thread tooltip (text-muted-foreground ancestor + grayscale opacity-60) — Droid will render noticeably fainter/tinted next to the sibling icons, while this PR's mobile ProviderIcon deliberately pins the mark to #171717 / #e5e5e5.
Consider pinning the same tone here so contextual opacity/grayscale still applies but the base color matches the other provider marks; keeping className last preserves call-site overrides.
-export const DroidIcon: Icon = (props) => (
- <svg {...props} viewBox="100 90 700 700" fill="currentColor">
+export const DroidIcon: Icon = ({ className, ...props }) => (
+ <svg
+ {...props}
+ viewBox="100 90 700 700"
+ className={cn("fill-[#171717] dark:fill-[#e5e5e5]", className)}
+ >Posted via Macroscope — UI Consistency
… progress The provider shipped without three things the design called for: Droid's slash commands and skills never reached the composer, subagent tool progress never reached the Agents panel, and the provider probe had no tests. One `droid exec` process now answers every inventory question, since startup is the expensive part and `list_models`, `list_commands`, and `list_skills` are all session-less. Commands and skills degrade to empty on an older CLI rather than costing us the live model catalog. Skills follow Droid's own user-facing rule and hide what a user cannot invoke, but carry disabled state through instead of dropping it. Tool progress maps to `tool.progress` only when Droid attributes it to a subagent session: that id is already the task id, and progress with no owner is discarded downstream anyway. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
| }); | ||
|
|
||
| // tool_result carries no tool name; remember tool_call names per session. | ||
| const toolUseNames = new Map<string, string>(); |
There was a problem hiding this comment.
🟡 Medium Layers/DroidAdapter.ts:902
toolUseNames is shared across all Droid sessions, so concurrent sessions that reuse a tool-use ID overwrite each other and the earlier session’s tool_result is emitted with the wrong canonical item type and title. The map is intended to be session-scoped, but droidToolUseName ignores its ctx argument; key the remembered names by session (or otherwise isolate them per DroidSessionContext).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 902:
`toolUseNames` is shared across all Droid sessions, so concurrent sessions that reuse a tool-use ID overwrite each other and the earlier session’s `tool_result` is emitted with the wrong canonical item type and title. The map is intended to be session-scoped, but `droidToolUseName` ignores its `ctx` argument; key the remembered names by session (or otherwise isolate them per `DroidSessionContext`).
| const slashCommands: ServerProviderSlashCommand[] = []; | ||
| for (const command of commands) { | ||
| const name = command.name.trim(); | ||
| if (!name || seen.has(name)) continue; |
There was a problem hiding this comment.
🟡 Medium Layers/DroidProvider.ts:127
buildDroidSlashCommands advertises commands with isExecutable: false, so users can select and send slash commands that Droid explicitly marks unavailable. Filter these entries before adding them, while retaining commands where isExecutable is omitted.
| if (!name || seen.has(name)) continue; | |
| if (command.isExecutable === false || !name || seen.has(name)) continue; |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidProvider.ts around line 127:
`buildDroidSlashCommands` advertises commands with `isExecutable: false`, so users can select and send slash commands that Droid explicitly marks unavailable. Filter these entries before adding them, while retaining commands where `isExecutable` is omitted.
| if (outcome.state === "cancelled") ctx.interruptedTurnIds.delete(turnId); | ||
| return; | ||
| } | ||
| ctx.pendingTurnMessageIds.clear(); |
There was a problem hiding this comment.
🟡 Medium Layers/DroidAdapter.ts:578
settleTurn emits turn.completed without closing the entries in ctx.openItemIds, so interrupted turns, process exits, or terminal notifications missing assistant_text_complete/thinking_text_complete leave streamed items permanently inProgress and carry their stale IDs into later turns. Tool calls have the same problem because tool_call emits item.started without adding the tool ID to openItemIds; when no tool_result arrives, cancellation or process failure leaves the tool row permanently inProgress. Close and clear all open item IDs during terminal turn settlement, and track tool-call IDs so they are included in that cleanup.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/DroidAdapter.ts around line 578:
`settleTurn` emits `turn.completed` without closing the entries in `ctx.openItemIds`, so interrupted turns, process exits, or terminal notifications missing `assistant_text_complete`/`thinking_text_complete` leave streamed items permanently `inProgress` and carry their stale IDs into later turns. Tool calls have the same problem because `tool_call` emits `item.started` without adding the tool ID to `openItemIds`; when no `tool_result` arrives, cancellation or process failure leaves the tool row permanently `inProgress`. Close and clear all open item IDs during terminal turn settlement, and track tool-call IDs so they are included in that cleanup.
Settings reported 44 models where the CLI reports 57. Droid marks the models a user configured in its own settings with `isCustom`, and the provider passed that flag straight through. T3's `isCustom` means something narrower: a slug the user typed into T3's custom-model field. Custom rows render from that config list, so all 13 of those models were dropped from the Models section rather than labelled. Probe models are now always `isCustom: false`, and `isCustom` and `noImageSupport` leave the wire schema since nothing reads them. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Description
What
T3 Code users with a Factory subscription had no way to drive Droid from T3's web, desktop, or mobile clients. This PR adds Droid as a sixth built-in provider, behind an Early Access card in Settings, disabled by default.
Why
Droid ships a native
stream-jsonrpcmode (NDJSON JSON-RPC 2.0 over stdio) that carries strictly more than its ACP bridge: spec mode, subagent sessions, session rewind, in-session model switching, and MCP injection. Integrating against the native protocol keeps one canonical codepath and loses nothing in translation. The CLI inherits the user's browser OAuth, matching T3's bring-your-own-subscription model, so the SDK (which requiresFACTORY_API_KEY) is deliberately not used.How
The server spawns
droid exec --input-format stream-jsonrpc --output-format stream-jsonrpcper thread and translates its notifications into orchestration events through a new adapter. Session identity is envelope-scoped: every notification carries the droid session id it belongs to, which is what makes rewind forks, automatic compaction, and spec-handoff successor sessions safe to run on one live process.Net effect for users: enable Droid in Settings, pick a Droid model (fetched live, 57 today), and get streaming turns, supervised approvals with real diffs and plan text, plan mode, subagents as tasks, checkpoint rollback, and resume across server restarts, on all three clients.
Visual Evidence
droid-provider-demo.mp4
Single uncut take against a real Factory account (4x speed): enable the provider, watch it authenticate, start a thread on a BYOK model in Supervised mode, stream a turn, approve the file-write permission, and open the resulting diff.
Repro Recipe
Create hello.txt containing exactly: hello from droid.argscarries the full content).+hello from droid, and the file exists on disk.Architecture
flowchart LR subgraph clients [web / desktop / mobile] UI[approvals, tasks, plan mode, context meter] end UI <-->|typed WS events| PS[ProviderService] PS --> AD[DroidAdapter] AD -->|JSON-RPC requests| RPC[DroidRpcClient] RPC <-->|NDJSON over stdio| CLI[droid exec] RPC -->|"{sessionId, notification}"| AD CLI -->|HTTP + bearer| MCP[T3 MCP server] PS -->|persist snapshot on turn.completed| DB[(provider_session_runtime)]DroidProtocol.tsowns the wire schemas (verified against the droid CLI source, with an explicit unknown-notification sentinel for forward compatibility).DroidRpcClientowns framing and a synchronizedRunning | ShuttingDown | Exitedlifecycle, so a request can never register against a dead process.DroidAdapterowns all mapping policy: envelope filtering, turn settlement, HITL, rewind, and the spec-handoff successor adoption.Related Issue
No tracking issue exists; the maintainer requested this directly. Opened as a draft for a first skim.
Reviewer Guide
Diff shape: ~45% server provider core (
apps/server/src/provider/droid/,Layers/Droid*), ~30% tests + mock agent, ~10% client surfaces (web + mobile), ~10% docs, ~5% contracts. Skip:Icons.tsx/ProviderIcon.tsx(SVG path data).Review depth: Deep. New subprocess protocol plus concurrency-heavy adapter; the invariants are worth tracing.
Read order (causal):
packages/contracts/src/settings.ts—DroidSettingssurface and defaults (disabled,binaryPath: "droid").apps/server/src/provider/droid/DroidProtocol.ts— wire truth: notification union, permission detail variants,{label, value}option normalization.apps/server/src/provider/droid/DroidRpcClient.ts— NDJSON framing and the lifecycle state machine; tracerequestregistration vs. process exit.apps/server/src/provider/Layers/DroidAdapter.ts— the core: trace one turn fromsendTurnthrough envelope filtering tosettleTurn, then the rewind and spec-handoff paths.apps/server/src/provider/Layers/ProviderService.ts— the one shared-file change:persistSessionSnapshotonturn.completedand after rollback.apps/server/src/provider/Layers/DroidAdapter.test.ts— 19 tests pinning the invariants above against the mock agent.Open for pushback: steering settlement tracks which pending message ids received a
create_messagedelivery and settles when a terminal covers the rest (DroidAdapter.ts,handleTurnCompleted). The droid CLI emits one live terminal for a coalesced run (verified in its source), so per-message counting alone hangs; if you would rather settle on the first matching terminal and drop late steer output, that is a defensible simpler rule.Risk & Impact
ProviderService.persistSessionSnapshotruns for every provider on everyturn.completed: one SQLite upsert per turn. It exists so mid-session resume-cursor changes (droid compaction, rewind, spec handoff) survive a server restart; other providers get harmless re-persists of an unchanged cursor.plan_approvalrequest type is additive; existing providers never emit it.How risk is contained: 247 scoped tests across contracts, server (protocol 25, RPC client 19, adapter 19, registries 74), web, and mobile; no schema migrations; single-commit revert restores the previous state.
Contract Delta
Additive only, in
packages/contracts:DroidSettings+providers.droid+DroidSettingsPatch(settings),DROID_DRIVER_KINDwith default model and aliases (model),plan_approvalinCanonicalRequestType, anddroid.jsonrpc.notification|requestraw-source tags (providerRuntime). No existing field changes shape or meaning.Verification
Behavior verified. Full E2E against the real droid CLI (v0.202.0) through the web client on a production Factory account, verified @
bfbd2217a: enable → version probe → auth detection → live model list (57) → streamed turn → supervised permission round-trip → file written with exact content → diff panel. Live testing surfaced and fixed two integration bugs before this PR:FACTORY_HOME_OVERRIDEreplaces the home directory rather than the.factorydir (auth detection), and droid rejectsmcpServersas an object map (it takes an array with{name, value}header pairs). A wire-level validation session against the CLI confirmed init/load/settings/close/rewind result shapes and both notification envelope forms.Regression coverage.
DroidAdapter.test.ts(19) pins turn lifecycle, HITL round-trips, interrupt-vs-completion priority, post-rewind straggler filtering, spec-handoff successor adoption, compaction token accounting, child-session task lifecycle, resumed rollback anchors, and both steering settlement patterns against a session-faithful mock agent; red-checked (settlement rule reverted → coalesced test hangs).DroidRpcClient.test.ts(19) pins framing, the exit-vs-register race, late-response diagnostics, and bounded diagnostics.DroidProtocol.test.ts(25) pins schema decodes including permission detail retention. Sentinel test:apps/server/src/provider/Layers/DroidAdapter.test.ts(post-rewind straggler case).Not tested. Spec handoff and subagent child sessions were exercised against the mock, not the live CLI: driving a real multi-session spec run to completion needs a long production session and the semantics were verified against the droid CLI source instead. ask_user file-based flows on mobile were not manually driven; the mapping is shared with web where the flow was exercised.
Standard validators. Format, lint (0/0), typecheck (contracts, server, web, mobile all clean), 247 scoped tests green; no repo-wide runs per repo policy.
Implementation map (41 files)
Contracts —
settings.ts,settings.test.ts,model.ts,providerRuntime.tsServer, protocol layer —
provider/droid/DroidProtocol.ts(+test),provider/droid/DroidRpcClient.ts(+test)Server, provider —
Layers/DroidAdapter.ts(+test),Layers/DroidProvider.ts,Services/DroidAdapter.ts,Drivers/DroidDriver.ts,builtInDrivers.ts,Layers/ProviderService.ts, registry testsServer, text generation —
textGeneration/DroidTextGeneration.ts,TextGeneration.tsServer, test tooling —
scripts/droid-mock-agent.tsWeb —
Icons.tsx,providerIconUtils.ts,providerDriverMeta.ts,session-logic.ts(+test),contextWindow.ts,DiagnosticsSettings.tsx,ProviderModelsSection.tsx,ComposerPendingApprovalPanel.tsxMobile —
ProviderIcon.tsx,modelOptions.ts,threadActivity.ts(+test),PendingApprovalCard.tsxDocs — new
user/providers-droid.md; updates toinstall.md,permission-modes.md,README.md,internals/{providers,overview,glossary}.mdBuilt by Factory Droid (Claude-family model, with parallel heavy worker subagents for the protocol layer, client surfaces, mock fidelity, and three independent reviews).
Note
Add Factory Droid as a built-in provider with adapter, RPC client, and text generation
DroidAdapterspawns a Droid CLI process over JSON-RPC stdio, translates Droid notifications into canonicalProviderRuntimeEventstreams, manages session/turn/item lifecycle, approvals, and user input.DroidProviderprobes the CLI for version, auth, and model discovery.DroidDriverregisters it as a built-in driver.DroidProtocolschemas (token usage, model info, session settings, MCP OAuth, etc.) and aDroidRpcClientwith NDJSON line parsing, request/response correlation, server-request handling, and timeout diagnostics. A mock agent script supports local testing.DroidTextGenerationbacked by the same RPC client for commit messages, PR content, branch names, and thread titles, with schema-validated output and timeout handling.DroidSettings(disabled by default),DROID_DRIVER_KIND, default modelclaude-opus-5, slug aliases (opus,sonnet,haiku), and a newplan_approvalcanonical request type. Web and mobile UI add the Droid icon, provider picker entry, and "Plan approval" labels for the newplanrequest kind.ProviderServicenow re-persists the durable session snapshot onturn.completedevents and afterrollbackThread, so the resume cursor tracks adapter-driven session id changes.persistSessionSnapshotin ProviderService.ts runs for all providers on everyturn.completedand after every rollback; failures are caught and logged but reviewers should verify no unexpected side effects from the extra persistence calls.📊 Macroscope summarized bfbd221. 33 files reviewed, 11 issues evaluated, 1 issue filtered, 9 comments posted
🗂️ Filtered Issues
docs/user/install.md — 0 comments posted, 1 evaluated, 1 filtered
@factory/cli. Factory's current official CLI quickstart specifiesnpm install -g droid; following this new table therefore does not install the currently supported Droid CLI and can prevent users from setting up the provider. [ Out of scope (triage) ]