Skip to content

feat(droid): add Factory Droid as a provider - #7993

Draft
factory-ain3sh wants to merge 3 commits into
pingdotgg:mainfrom
factory-ain3sh:feat/droid-provider
Draft

feat(droid): add Factory Droid as a provider#7993
factory-ain3sh wants to merge 3 commits into
pingdotgg:mainfrom
factory-ain3sh:feat/droid-provider

Conversation

@factory-ain3sh

@factory-ain3sh factory-ain3sh commented Aug 23, 2026

Copy link
Copy Markdown

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-jsonrpc mode (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 requires FACTORY_API_KEY) is deliberately not used.

How

The server spawns droid exec --input-format stream-jsonrpc --output-format stream-jsonrpc per 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

npm install -g @factory/cli && droid   # sign in via browser, then exit
vp run dev --home-dir "$(mktemp -d)"   # open the pairingUrl it prints
  1. Settings → Providers → Droid → Enable. The card should move to "Authenticated · Factory account".
  2. New thread in any small repo, pick a Droid model, set Runtime mode to Supervised.
  3. Send Create hello.txt containing exactly: hello from droid.
  4. Approve the file-change request when it appears (the detail shows the file path; args carries the full content).
  5. Confirm the turn completes, the diff shows +hello from droid, and the file exists on disk.
  6. Restart the server, reopen the thread, send a follow-up: the session resumes instead of starting over.

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)]
Loading

DroidProtocol.ts owns the wire schemas (verified against the droid CLI source, with an explicit unknown-notification sentinel for forward compatibility). DroidRpcClient owns framing and a synchronized Running | ShuttingDown | Exited lifecycle, so a request can never register against a dead process. DroidAdapter owns 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):

  1. packages/contracts/src/settings.tsDroidSettings surface and defaults (disabled, binaryPath: "droid").
  2. apps/server/src/provider/droid/DroidProtocol.ts — wire truth: notification union, permission detail variants, {label, value} option normalization.
  3. apps/server/src/provider/droid/DroidRpcClient.ts — NDJSON framing and the lifecycle state machine; trace request registration vs. process exit.
  4. apps/server/src/provider/Layers/DroidAdapter.ts — the core: trace one turn from sendTurn through envelope filtering to settleTurn, then the rewind and spec-handoff paths.
  5. apps/server/src/provider/Layers/ProviderService.ts — the one shared-file change: persistSessionSnapshot on turn.completed and after rollback.
  6. 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_message delivery 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.persistSessionSnapshot runs for every provider on every turn.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.
  • The new canonical plan_approval request type is additive; existing providers never emit it.
  • Everything else is new code behind a disabled-by-default provider; a user who never enables Droid executes none of the adapter.

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_KIND with default model and aliases (model), plan_approval in CanonicalRequestType, and droid.jsonrpc.notification|request raw-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_OVERRIDE replaces the home directory rather than the .factory dir (auth detection), and droid rejects mcpServers as 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)

Contractssettings.ts, settings.test.ts, model.ts, providerRuntime.ts
Server, protocol layerprovider/droid/DroidProtocol.ts (+test), provider/droid/DroidRpcClient.ts (+test)
Server, providerLayers/DroidAdapter.ts (+test), Layers/DroidProvider.ts, Services/DroidAdapter.ts, Drivers/DroidDriver.ts, builtInDrivers.ts, Layers/ProviderService.ts, registry tests
Server, text generationtextGeneration/DroidTextGeneration.ts, TextGeneration.ts
Server, test toolingscripts/droid-mock-agent.ts
WebIcons.tsx, providerIconUtils.ts, providerDriverMeta.ts, session-logic.ts (+test), contextWindow.ts, DiagnosticsSettings.tsx, ProviderModelsSection.tsx, ComposerPendingApprovalPanel.tsx
MobileProviderIcon.tsx, modelOptions.ts, threadActivity.ts (+test), PendingApprovalCard.tsx
Docs — new user/providers-droid.md; updates to install.md, permission-modes.md, README.md, internals/{providers,overview,glossary}.md

Built 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

  • Introduces a full Droid provider stack: DroidAdapter spawns a Droid CLI process over JSON-RPC stdio, translates Droid notifications into canonical ProviderRuntimeEvent streams, manages session/turn/item lifecycle, approvals, and user input. DroidProvider probes the CLI for version, auth, and model discovery. DroidDriver registers it as a built-in driver.
  • Adds DroidProtocol schemas (token usage, model info, session settings, MCP OAuth, etc.) and a DroidRpcClient with NDJSON line parsing, request/response correlation, server-request handling, and timeout diagnostics. A mock agent script supports local testing.
  • Adds DroidTextGeneration backed by the same RPC client for commit messages, PR content, branch names, and thread titles, with schema-validated output and timeout handling.
  • Contracts gain DroidSettings (disabled by default), DROID_DRIVER_KIND, default model claude-opus-5, slug aliases (opus, sonnet, haiku), and a new plan_approval canonical request type. Web and mobile UI add the Droid icon, provider picker entry, and "Plan approval" labels for the new plan request kind.
  • ProviderService now re-persists the durable session snapshot on turn.completed events and after rollbackThread, so the resume cursor tracks adapter-driven session id changes.
  • Risk: persistSessionSnapshot in ProviderService.ts runs for all providers on every turn.completed and 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
  • line 62: The Droid install command uses the obsolete package @factory/cli. Factory's current official CLI quickstart specifies npm 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) ]

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>
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e645ef1-c017-4abd-9a5e-67f886e92f13

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 23, 2026
initialized.kind === "loaded" ? initialized.result.lastCallTokenUsage : undefined,
currentModelId: requestedModelId,
currentReasoningEffort: requestedEffort,
currentInteractionMode: "auto",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +217 to +241
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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).

Comment on lines +740 to +742
const entry = state.pending.get(requestId);
if (entry === undefined || entry._tag !== "Pending" || entry.deferred !== deferred) {
return state;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment on lines +107 to +109
return {
slug,
name: model.displayName?.trim() || slug,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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: [] });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +44 to +57
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;
}> {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +1639 to +1648
Effect.catchTag("SchemaError", (cause) =>
Effect.fail(
new ProviderAdapterRequestError({
provider: PROVIDER,
method: "droid.execute_rewind",
detail: "Failed to decode Droid rewind result.",
cause,
}),
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Statically known tagged failures should be recovered with Effect.catchTags({ ... }), including when only one tag is handled — Effect.catchTag is not the convention here.

Suggested change
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

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +217 to +218
export const DroidIcon: Icon = (props) => (
<svg {...props} viewBox="100 90 700 700" fill="currentColor">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant