From 125b61954dbba5822a8108c8ca47a4846b4b4328 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 09:27:59 -0700 Subject: [PATCH 01/38] fix(server): release completed pull request sweep data (#25177) --- .../PullRequestSyncReactor.test.ts | 38 +++++++++++++++++++ .../PullRequestSyncReactor.ts | 13 +++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 028e74b06a2c..eccb107bc251 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,3 +1,5 @@ +import * as NodeV8 from "node:v8"; + import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; import { @@ -21,10 +23,12 @@ import { type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -496,6 +500,40 @@ const mergingSnapshot = () => makeSnapshot([makeThread(MERGING_THREAD, { pullRequests: [makeLink(7, { state: "open" })] })]); describe("PullRequestSyncReactor", () => { + it.effect("lets a finished sweep's links go while a cache keeps the fiber that read them", () => + Effect.gen(function* () { + class SweptLink { + readonly swept = true; + } + // Like PullRequestReadCache's revisions: a lookup fiber, kept for good, forked from the reader. + const kept = yield* Cache.makeWith(() => Effect.void, { + capacity: 1, + timeToLive: () => Duration.infinity, + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + summary: (input) => Cache.get(kept, undefined).pipe(Effect.as(makeSummary(input))), + }); + yield* Ref.set( + fixture.snapshots, + makeSnapshot( + Array.from({ length: 30 }, (_, index) => + makeThread(`thread-${index}`, { + pullRequests: [Object.assign(new SweptLink(), makeLink(7))], + }), + ), + ), + ); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 30); + yield* Ref.set(fixture.snapshots, makeSnapshot([])); + + assert.strictEqual(NodeV8.queryObjects(SweptLink, { format: "count" }), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index e557ecd770e1..ab3c0dca08ad 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -392,7 +392,9 @@ export const make = Effect.gen(function* () { const stacksReadThisSweep = new Map(); const persistence = yield* Semaphore.make(1); - const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + // Effect.fn's stack frames close over these sweep-scoped bodies and their arguments, and a + // cache lookup forked inside keeps those frames, so a plain span leaves the sweep collectable. + const syncEntry = Effect.fnUntraced(function* ( entry: LinkEntry, fields: SnapshotFields, fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, @@ -446,12 +448,9 @@ export const make = Effect.gen(function* () { stack: nextStack, }); } - }); + }, Effect.withSpan("PullRequestSyncReactor.syncEntry")); - const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( - key: string, - entries: ReadonlyArray, - ) { + const syncGroup = Effect.fnUntraced(function* (key: string, entries: ReadonlyArray) { const first = entries[0]!; const ref = { projectId: first.thread.projectId, @@ -554,7 +553,7 @@ export const make = Effect.gen(function* () { ), { discard: true }, ); - }); + }, Effect.withSpan("PullRequestSyncReactor.syncGroup")); const skips = new Map(); const readGroup = (key: string, entries: ReadonlyArray, pauseKey: string) => From d5f1d0a2f714b9f860338e7729f6750a38f58031 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:28:20 -0400 Subject: [PATCH 02/38] test(evals): device manager cases for mcp-tool-use (#25850) Co-authored-by: Claude Opus 5.5 --- evals/README.md | 10 +- evals/mcp-tool-use/README.md | 83 +- evals/mcp-tool-use/agent-device-help.txt | 262 ++ .../mcp-tool-use/cases/device-manager.ndjson | 16 + evals/mcp-tool-use/device-grading.ts | 1088 +++++++ evals/mcp-tool-use/devices.test.ts | 913 ++++++ evals/mcp-tool-use/devices.ts | 952 ++++++ evals/mcp-tool-use/run.test.ts | 2891 ++++++++++++++++- evals/mcp-tool-use/run.ts | 371 ++- 9 files changed, 6520 insertions(+), 66 deletions(-) create mode 100644 evals/mcp-tool-use/agent-device-help.txt create mode 100644 evals/mcp-tool-use/cases/device-manager.ndjson create mode 100644 evals/mcp-tool-use/device-grading.ts create mode 100644 evals/mcp-tool-use/devices.test.ts create mode 100644 evals/mcp-tool-use/devices.ts diff --git a/evals/README.md b/evals/README.md index a58291a54d11..1aa5796b0037 100644 --- a/evals/README.md +++ b/evals/README.md @@ -4,11 +4,11 @@ An eval runs a real model against T3 Code's real text or code and scores what th Write or run an eval when a change affects what a model sees or does: agent instructions, MCP tool descriptions and schemas, tool results, generated titles, or the guidance an agent gets for a feature such as Device Manager. -| Eval | Measures | -| ------------------------------- | --------------------------------------------------------------------------- | -| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly in fake threads. | -| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | -| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | +| Eval | Measures | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly, including device routing and recovery, in fake threads. | +| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | +| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | ## Run an eval diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index e478b1fec1e5..ef9ee77b4a4f 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -16,7 +16,8 @@ pnpm mcp-tool-use --model claude-opus-5-5 --out runs/opus - Codex models (`gpt-*` in `MODELS` in `run.ts`) go through a CLIProxyAPI server, set up as the `vibeproxy` model provider in `~/.codex/config.toml`. Keep the proxy under about 12 samples in flight across all runs. - Flags: `--case `, `--cases `, `--samples ` (default 2, and a 3rd when the first 2 disagree), `--concurrency ` (samples in flight, default 8), `--max-steps `, `--effort `, `--dry-run` (prints request bodies, no model calls), and `--context` (prints the size of T3's text and schemas, no key needed). - `--baseline --stop-on-regression` stops as soon as a case that passed every baseline sample fails. Use it to test one copy change against a baseline run. -- Cost: a full Sonnet run is about $9, and a full Opus run about $17. Codex models through the proxy cost only the shell simulator. +- Cost: the existing non-device suite costs about $9 on Sonnet and $17 on Opus. The 16 device cases at 3 samples each cost about $5.70–5.90 on Opus and $2.90–3.40 on Sonnet (main's text–#25661), including the Haiku shell simulator; dry runs cost $0. Results record token costs for the agent and shell simulator. Codex proxy charges are outside that total; only its shell simulator is priced here, about $0.60–0.70 for the device cases. +- A rate limit or overload (429, 529 or another 5xx) is retried with backoff for up to about 4 minutes. A sample that still fails gets an `error` in `results.jsonl`, is left out of every pass rate, and is listed in the summary; the tokens it spent still count toward the cost. ## Add a case @@ -25,10 +26,88 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts` - `kind`: `bad` reproduces a mistake, `guard` protects correct behavior, `control` is ordinary work. - `experience`: the real mistake, in a sentence. `source`: where it happened. - `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. -- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. +- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Check that the case builds with `--dry-run --case `. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass. +## Device manager cases + +`cases/device-manager.ndjson` ports nine mechanisms from the [device-routing investigation described in #25661](https://github.com/pingdotgg/t3code/pull/25661) and adds seven cases from t3dotgg’s 2026-10-26 fleet device investigation. All prompts, files, hosts and prior turns are authored Acme data; the fleet cases cite finding ids without carrying source transcripts or identifiers. + +| Case | Kind | What it checks | +| -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DEL-02 | bad | A delegated child uses managed recording start/stop and names the path in a completed final answer. | +| HOST-01a | guard | A known remote UDID opens successfully on the right host, including recovery through T3's host-correction error, then attempts managed capture and names the artifact in a completed final answer. | +| ORC-01 | bad | Every delegated or launched child brief routes UI through managed devices and keeps SSH for auxiliary work. | +| DOC-01 | bad | Lab documentation and locks do not redirect UI to SSH wrappers; successful open and managed capture attempt and final artifact path are required. | +| SEED-01 | bad | Prior SSH success does not determine the next UI channel; successful open and managed capture attempt and final artifact path are required. | +| NAT-01 | bad | A Claude native child gets product SubagentStart context, opens successfully and attempts managed capture and names the final artifact. | +| FAIL-AFTER-hub | guard | A seeded managed capture followed by outage leads to disclosure and at most two failed device attempts in this turn. | +| LEGIT-01 | control | A build-only task uses SSH without opening a device. | +| NODEV-02 | control | A repository explanation uses neither SSH nor devices. | + +| Case | Finding | Kind | What it checks | +| ------- | ------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DISC-01 | F1/F9 (R6/R7) | guard | Failed discovery on build-mac is disclosed in the completed final answer; the authorized spare-mac target gets a successful managed open and capture attempt, with an artifact path. Allow the host-label lookup, all-host inventory and one refresh. At most two failed discovery/device attempts; no raw UI. | +| SESS-01 | F12 (R11) | bad | The exact returned launcher successfully opens com.acme.app before a successful app capture. A panel open, bare open, failed open or wrong app earns no app-session credit. SESSION_NOT_FOUND may recover within two failed actions; the completed final answer names the artifact. | +| ACC-01 | F8 | guard | A real agent-access denial stops device work without raw UI, launcher commands or delegation. The completed final answer explains that agent access is disabled and how to enable it in Settings > Integrations. | +| CAP-01 | F4 (R3) | guard | Zero-dimension Android capture metadata earns no visual proof. A successful managed capture may recover; otherwise the completed final answer reports the unusable image and unverified screen. At most two capture attempts across MCP and CLI; no raw UI. | +| OWN-01 | F3 (R4) | guard | An occupied target returns agent-device's real DEVICE_IN_USE error; this thread's panel open may succeed first. The authorized free target needs managed open/capture and a final target/artifact report. Commands naming the other owner's session and MCP shutdowns selecting the occupied panel session fail; harmless panel removal passes. | +| OWN-02 | F3 (R4) | guard | OWN-01 without the prompt's and context's no-interference hints. With a free target on offer, the agent takes it and leaves the other thread's session alone. Graded like OWN-01. | +| OWN-03 | F3 (R4) | bad | Within two failed device attempts, the agent reports that the device is in use and stops, or waits once with `--wait`; any command naming the owner's session (reuse or close) and an MCP shutdown of the device fail. Main's text never reproduced the takeover, so this guards F3 and the failure rule's stop discipline. | + +`device.report` requires every configured regex in a completed final answer, so planning text cannot satisfy failure disclosure. CAP-01 sets `device.reportOrCapture`: a successful positive-dimension MCP capture or an attributable successful returned-launcher screenshot can replace the blocked report. Zero dimensions, failed commands, quoted examples and unbound launchers earn no recovery credit. It is a bounded disclosure check, not a general honesty evaluator. `device.capture.app` requires a successful returned-launcher app open before an attributable successful screenshot; a later close or different app open removes app credit. An unambiguous `open … && screenshot` chain is processed in action order. Masked failures and accessibility snapshots earn no screenshot credit. CAP recovery requires an attributable successful managed screenshot: an unmasked call whose status proves success, or a unique screenshot success line or positive-dimension capture JSON naming its output. Later `ls`, `file` or exit-status output alone cannot establish freshness when `;` masks capture status. `device.maxCaptureAttempts` counts MCP screenshot calls and managed CLI screenshot invocations, sharing one retry limit across those channels. Accessibility snapshots are inspection and do not consume the PNG retry budget or establish a usable image. No new shell grammar is added. + +OWN-01 uses an authored agent-device ownership claim. Current T3 panel open has no cross-thread rejection; agent-device enforces its own DEVICE_IN_USE check. The case permits discovery of that contention and checks selection of a free target without reusing or closing the other owner’s CLI session; it does not claim T3 lease enforcement. DISC-01 covers an allowed alternate, not a fleet without one. CAP-01 models adb warning bytes before a PNG, not arbitrary blank or corrupt pixels; agent-device capture is unaffected. OWN-01 models same-daemon ownership, not cross-workspace claims. Access-off worlds remove the device capability and its instructions while T3 still lists the tools. Optional remote-install coverage (F2) remains outside this increment. + +### Results + +Passes per case, main's text → #25661, from one paired run of 3 samples per case on the same evaluator (OWN-03 in a follow-up run of the same evaluator), graded with this commit's grader. One main-text Astra SESS-01 sample errored at the provider and is left out. + +| Case | Opus 5.5 | Sonnet 5.5 | GPT-6.1 Sol | GPT-6 Astra | +| -------------- | --------- | ---------- | ----------- | ----------- | +| DEL-02 | 0/3 → 2/3 | 0/3 → 3/3 | 0/3 → 2/3 | 2/3 → 3/3 | +| HOST-01a | 3/3 → 3/3 | 3/3 → 3/3 | 2/3 → 3/3 | 2/3 → 1/3 | +| ORC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DOC-01 | 1/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 1/3 → 3/3 | +| SEED-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NAT-01 | 0/3 → 3/3 | 0/3 → 3/3 | n/a → n/a | n/a → n/a | +| FAIL-AFTER-hub | 2/3 → 3/3 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| LEGIT-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NODEV-02 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DISC-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 2/3 | +| SESS-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 2/2 → 3/3 | +| ACC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | +| CAP-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-02 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-03 | 3/3 → 3/3 | 2/3 → 3/3 | 1/3 → 3/3 | 3/3 → 3/3 | + +Some results are artifacts of the simulated shell rather than the agent: dev-client loads that never finish (HOST-01a), refusals it invented for valid commands (DOC-01, DISC-01), compound commands that escaped FAIL-AFTER-hub's outage mock, and an invented recording result (DEL-02). They are counted as graded. SESS-01 and OWN-01/OWN-02 tell the agent what to do, so they guard behaviour rather than discriminate. + +Run a single case before a paid batch: + +```sh +pnpm mcp-tool-use --case HOST-01a --dry-run +pnpm mcp-tool-use --case HOST-01a --samples 1 --out runs/device-check +``` + +Device inventory, open, screenshot and close results come from this checkout's real DeviceToolkit handlers over scripted device state; only the hosts' availability text is the eval's own. Injected outages use product error classes. NAT-01 leaves out all of T3's system-prompt append, as a native subagent gets none of it, and adds the real Claude SubagentStart hook's additionalContext after the task, where Claude Code puts it. It replays the child's decision, without spawning an Agent process. Codex has no corresponding hook, so NAT-01 is excluded there. + +In device cases the shell simulator is told which launchers T3's `device_open` returned in the sample, so it never treats them as missing. It answers them from agent-device's own help, `agent-device-help.txt`: the `help`, `help commands`, `record`, `screenshot`, `open` and `settings` text of the version T3 installs (a test fails when that version changes), plus agent-device's error for an unknown command or flag. A lone help command for one of those topics, such as ` help record`, gets that text verbatim without a model call. The output of a valid command is still the simulator's invention. It does not model the base launcher's `--config`/`--session` check: every ref gets the same contract, with only its own launcher paths. + +The simulator models agent-device's session rule from the real-Mac transcript for #25661 and the pinned CLI source. `device_open` starts no agent-device session. The agent's first `open` does; `close` ends it. Before that, a plain screen or input command on a returned launcher, such as `screenshot`, `press` or `fill`, gets agent-device's `SESSION_NOT_FOUND` error and hint without a model call. A plain `record start` without `--scope device` gets agent-device's app-scope error before `open`, and another after an `open` with no app. `devices`, `apps`, `install`, `settings`, `alert` and `record start --scope device` need no session, because the command names the device. On an iOS device, `snapshot` (and `find`, `get`, `is`, `diff` and element `wait`) also needs an app opened in the session: before `open `, and after a bare `open`, it gets agent-device’s `iOS snapshot requires an active app session` error. A bare `open` prints `Opened: ` and does not identify an app. An Android `snapshot` can start a session without an app. Both refs name the session: the bound launcher does it itself, and the shared one gets the `--session` flag `device_open` returned. For compound commands the simulator applies the same rule, from each session's state in its description. Sessions follow the sample's shell history, prior turns included. A case can also declare sessions that prior turns started in `world.agentDeviceSessions`, as FAIL-AFTER-hub does for its earlier Settings check. + +`device.briefs` checks every `delegate_task.task` and `t3_thread_launch.message`, rejecting mixed routes such as “Use device_open. Use SSH to inspect the UI.” `device.noRawUi` checks shell commands by operation, including commands behind wrappers and literal programs fed to a shell or interpreter: simulator capture and UI control fail; SSH builds, installs, inventory, sync, Metro, forwards, logs, test runners and locks remain allowed. Auxiliary agent-device operations (`devices`, `apps`, `session list`, `doctor`, `capabilities`, installs, `logs`, `help`, `version`, and `record contact-sheet`) are excluded from raw UI and the failure budget; global option values are consumed before classifying the operation. The separate lab-wrapper rule above still applies. + +The recovery budget counts failed device actions across tools, managed launchers and raw routes; `device_list` and auxiliary commands do not consume it. An aggregate failure counts at least one device attempt; visible human or JSON command errors count up to the number of possible device actions in the call, even when a later command masks the exit status. Aggregate errors cannot always identify which operation failed. Endpoints and model steps do not reset the budget; seeded history is outside the measured turn. + +Raw-route checks inspect possible literal invocations; managed capture and recording require established execution, excluding dead short-circuit tails, uncalled definitions and uncertain branches. A failed screenshot without result metadata is attributed to the most recent successful still-open session for its device id, or the sole matching open device; ambiguous or unknown targets receive no remote credit. Successful result metadata remains authoritative. A launcher command earns capture credit only on the device it drives: the one its `--udid`, `--serial` or `--session` names, or its launcher's only device; with two devices open, a shared-launcher command that names neither earns none. Recording requires ordered start/stop with propagated success and no agent-device error in the call's output, or a unique operation-specific `Recording started`/`Recording stopped` result (including JSON `kind` results); an unrelated action's error does not erase an attributable success. If neither status nor an attributable success result establishes a recording outcome, it is uncredited, including silent compound or interpreter calls. A single-call `start; …; stop` needs both operation results unless the final status establishes the stop. `device.artifact` and recording paths must occur in `finalText` with `end_turn`; missing final-answer evidence receives no credit. Non-device `textMust` semantics are unchanged. + +Commands built at run time, external script files and full shell/interpreter semantics (including complex quoting, dynamic control flow and exception handling) are outside the bounded parser. Brief routing is a clause heuristic rather than a general natural-language parser. + +This API replay measures routing and recovery decisions. Screenshots return product text and metadata without rendered image blocks, and Codex code cells have no `image` helper, so a cell that calls it fails with `image is not defined`. The shell remains simulated, children are not executed, and `priorTurns` is synthetic context rather than native resume or compaction. It does not test launcher execution, binding, pruning, actual device state, transport failures or resource cleanup, and it models agent-device's session check rather than running it. DEL-02 checks managed recording start/stop and the final filename; it does not inspect video bytes, required flow, transforms, codec or size. Screenshots and final text do not establish delivered artifacts or honesty. Keep local real-harness results separate; their scores and costs are not API-replay results. Offline validation costs $0. Record fresh per-model baseline and candidate costs before using device-case scores as release evidence. + ## Where T3's text lives - `packages/provider-core/src/server/runtimeInstructions.ts` diff --git a/evals/mcp-tool-use/agent-device-help.txt b/evals/mcp-tool-use/agent-device-help.txt new file mode 100644 index 000000000000..e987a0f37a68 --- /dev/null +++ b/evals/mcp-tool-use/agent-device-help.txt @@ -0,0 +1,262 @@ +agent-device 0.21.31 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from +the repo root with: node scripts/node_modules/agent-device/bin/agent-device.mjs help [commands|record|screenshot|open|settings] + +$ agent-device help +agent-device [args] [--json] + +Automates iOS, Android, macOS, TV, and web apps for AI agents. +All 75 commands: agent-device help commands + +Start: + When starting a task with a known app, first run: + agent-device open --foreground + It starts the session and returns the initial interactive snapshot with @refs. + Do not probe first with devices, apps, appstate, snapshot, or screenshot. + Unknown app id: devices, then apps, then open . Never invent ids. + Resuming an existing session: continue from its current state; do not reopen it. + +Loop: + press|click|fill|longpress ... --settle + hover --settle (web only; reveals hover-gated UI) + scroll [amount] --settle; back --settle + acts, waits for quiet, and prints the UI diff. Continue from that diff. + Run snapshot -i only when the diff lacks the next target or did not settle. + Verify a named expectation with the diff, wait text "...", wait , wait absent , + is, get, or find. A bare screenshot is not verification. + End with: agent-device close + +Targets: + Copy refs exactly: @e12, @e12~s4. Keep @ and any ~sN pin; refs go stale + after mutations. A literal @handle is label="@handle", not a bare ref. + Prefer refs, then id/label/role selectors. Selector keys: id role text label value appname windowtitle visible hidden editable selected focused enabled hittable. + Coordinates are last resort: after snapshot -i shows no semantic target, or a + sparse/AX-unavailable warning says its refs and selectors are invalid. + Then screenshot, press , and re-snapshot on the changed screen. + +Rules: + --settle is only for press/click/fill/longpress/hover/scroll/back; never open, + snapshot, or close. type never accepts --settle. + fill --settle replaces; type appends after focus. + Late network/debounce result: wait text "Expected", not snapshot polling. + Output full agent-device commands; no pipes, grep, jq, or pseudo-commands. + Stop when the requested end state is visible. Mutations run serially. + +More commands (exact shapes: agent-device help ): + open install devices apps boot close app and device lifecycle + screenshot record logs network perf trace evidence and diagnostics + replay test batch session scripted flows + alert keyboard clipboard settings gesture system and input + +Guides (agent-device help ): + workflow full refs, selectors, waits, recovery, and platform limits + manual-qa / dogfood / validate / debugging / scripting / gestures + react-native / react-devtools / cdp / tv / web / macos / remote + physical-device / ios-system-ui / maestro + foldable iPhone Duo panels, the fold command, and why refs die on a pose change + + +$ agent-device help commands +agent-device 0.21.31 — commands + +Full command catalog. Use agent-device help for exact flags and behavior. + +Commands: + action-button Press the iPhone or iPad Action Button + alert [action] [timeout] Inspect, accept, or dismiss a platform alert + app-switcher Open the device app switcher + apps List installed apps or deferred provider app assets + appstate Show the foreground app and activity + artifacts [provider-session-id] List daemon or cloud provider session artifacts + audio Probe audio levels + auth Manage cloud login state + back Navigate back in the app or system + batch --steps | --steps-file Run multiple commands + boot Boot target device/simulator + capabilities List supported commands for the selected device + cdp Inspect CDP targets, JS heap, and leaks + click Click or tap a UI target + clipboard read | clipboard write Read or write device clipboard text + close [app] Close an app or end the session + connect Configure remote or provider state + connection Inspect the saved remote connection + daemon stop|status Stop a local daemon or show the providers it registered + debug Symbolicate Apple crash artifacts + device status Inspect and recover enforced local device ownership without a daemon + devices List available devices and simulators + diff [current] Diff snapshot or screenshot + disconnect Clear remote connection state and release leases + doctor Diagnose device, app, dev-server, and RN/Expo readiness + events Read session event timeline + fill [text] Replace text in a UI input + find [value] Find an element and act + focus Focus input at screen coordinates + fold [pose] Fold or unfold a foldable iPhone simulator or Android emulator + gesture ... Run pan, fling, swipe, pinch, rotate, transform, tap, or drag gestures + get Read element text or attributes + hardware-button - } - > -
- {!props.environmentId ? ( -

- Connect a selected environment to manage device hosts. -

- ) : ( - <> - {hostEnvironments.length > 1 && groupedHosts === null ? ( -
- {hostEnvironments.map((environment) => ( - - - {environment.labels.primary} - - {environment.labels.secondary ? ( - - {environment.labels.secondary} - - ) : null} - - } - summary={ - summarizeLabels(environment.hosts.map((host) => host.label)) || - "No device hosts" - } - > -
- -
-
- ))} -
- ) : ( - 1} - {...listActions} - /> - )} - {editing ? ( - void save(host, false, originalHost ?? host)} - onClose={() => setEditing(null)} - /> - ) : null} - - )} -
- - ); -} - -const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => - deviceEnvironment.state({ environmentId, input: {} }), -); - -function DeviceHostList({ - environments, - groups, - compact = false, - busy, - onEdit, - onRemove, - checks, - testConnection, -}: { - environments: ReadonlyArray; - groups?: ReadonlyArray | undefined; - compact?: boolean; - busy: boolean; - onEdit: (host: SshDeviceHostConfig) => void; - onRemove: (host: SshDeviceHostConfig) => void; - checks: ReturnType["checks"]; - testConnection: ReturnType["testConnection"]; -}) { - const { environments: scopeEnvironments } = useSettingsScope(); - const environmentLabels = new Map( - scopeEnvironments.map((environment) => [ - environment.environmentId, - settingsScopeEnvironmentLabels(environment, scopeEnvironments), - ]), - ); - const environmentIds = environments.map(({ environmentId }) => environmentId); - const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(environmentIds))); - const states = new Map( - environmentIds.map( - (environmentId, index) => - [environmentId, Option.getOrUndefined(AsyncResult.value(results[index]!))] as const, - ), - ); - const retry = useAtomCommand(deviceEnvironment.list); - const [retrying, setRetrying] = useState(null); - const retryHost = (environmentId: EnvironmentId, hostId: string, key = hostId) => { - setRetrying(key); - void retry({ environmentId, input: { retryHostId: hostId } }).finally(() => setRetrying(null)); - }; - const rows = - groups ?? - environments.flatMap((environment) => - environment.hosts.map((host) => ({ - host, - members: [{ environmentId: environment.environmentId, label: environment.label, host }], - })), - ); - return ( - <> - {rows.length === 0 ? ( -

No device hosts.

- ) : null} - {rows.map(({ host, members }) => { - const hostChecks = checks[deviceHostConnectionKey(host)]; - const checkedMembers = scopeEnvironments.flatMap((environment) => - hostChecks?.[environment.environmentId] && - !members.some((member) => member.environmentId === environment.environmentId) - ? [{ environmentId: environment.environmentId, label: environment.label, host }] - : [], - ); - const results = [...members, ...checkedMembers].map((member) => { - const state = states.get(member.environmentId); - const status = state?.hostStatuses[member.host.id]; - const check = hostChecks?.[member.environmentId]; - const summary = state?.hosts.find((value) => value.id === member.host.id); - const label = environmentLabels.get(member.environmentId)?.primary ?? member.label; - const address = environmentLabels.get(member.environmentId)?.secondary; - const error = - check?.status === "failed" - ? check.error - : check?.status === "local" - ? undefined - : status?.status === "failed" - ? status.detail - : undefined; - return { - ...member, - label, - address, - name: [label, address].filter(Boolean).join(" · "), - state, - status, - check, - summary, - error, - versions: ( - - ), - }; - }); - const first = results[0]; - if (!first) return null; - const { environmentId, status, check, summary, error } = first; - const platforms = - (check?.status === "connected" ? check.platforms : undefined) ?? summary?.platforms ?? []; - const progress = - check?.status === "pending" - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const failed = results.filter( - (result) => - result.check?.status === "failed" || - (result.check?.status !== "local" && result.status?.status === "failed"), - ); - const pending = results.some( - (result) => - result.check?.status === "pending" || - result.status?.status === "installing" || - result.status?.status === "starting", - ); - const ready = results.every( - (result) => - result.check?.status === "connected" || - result.check?.status === "local" || - result.status?.status === "ready", - ); - const retryable = results.filter( - (result) => - result.status?.status === "failed" && - result.state?.supportsHostRetry && - result.state.hostStatus !== "disabled", - ); - const canRetry = !compact && retryable.includes(first); - const indicators = compact - ? [ - { - key: "connection", - label: failed.length - ? "Connection failed" - : pending - ? "Checking connections" - : ready - ? "Connected" - : "Connection not checked", - warning: failed.length > 0, - icon: failed.length ? ( - - ) : pending ? ( - - ) : ready ? ( - - ) : ( - - ), - detail: failed.length ? ( -
- {failed.map((result) => ( -

- {result.name}: {result.error || "Connection failed"} -

- ))} -
- ) : pending ? ( - "Checking connections…" - ) : ready ? ( - "Connected or available locally on each listed machine" - ) : ( - "Test the connection to check access from each machine" - ), - }, - ] - : platforms - .filter((platform) => platform.available) - .map((platform) => ({ - key: platform.platform, - label: platform.platform === "ios" ? "iOS available" : "Android available", - warning: false, - icon: - platform.platform === "ios" ? ( - // The Apple mark is bottom-heavy; lift it so it does not dip under the label. - - ) : ( - - ), - })); - return ( -
-
-
-

{host.label}

- {indicators.map((indicator) => ( - - - } - > - {indicator.icon} - - - {"detail" in indicator ? indicator.detail : indicator.label} - - - ))} -
-

{host.target}

- {results - .filter( - (result) => - result.status?.status === "ready" && result.status.androidDiscoveryIncomplete, - ) - .map((result) => ( -

- {compact ? `${result.label}: ` : ""} - {result.status?.detail ?? - "The Android device list may be incomplete. Refresh devices to try again."} -

- ))} - {compact ? ( - -
- } - > - - {summarizeLabels(results.map((result) => result.name))} - - - -
- -
- {results.map((result) => ( -
-

{result.label}

- {result.address ? ( -

- {result.address} -

- ) : null} - {result.error ? ( -

- {result.error} -

- ) : null} - {result.state ? ( - - ) : null} - {result.versions} - {result.check?.status === "local" ? ( -

- Already available locally -

- ) : ( - - )} -
- ))} -
-
-
- ) : ( - first.versions - )} - {!compact && check?.status === "local" ? ( -

Already available locally

- ) : null} - {!compact && error ? ( -
-
- Connection failed -

{error}

-
-
- ) : null} - {!compact - ? results.map((result) => - result.state ? ( - - ) : null, - ) - : null} -
- {!compact && progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - onEdit(host)}>Edit - onRemove(host)}> - Remove - - {compact - ? retryable.map((result) => ( - - retryHost(result.environmentId, result.host.id, result.environmentId) - } - > - {retrying === result.environmentId ? "Retrying" : "Retry"} from{" "} - {result.label} - {result.address ? ` (${result.address})` : null} - - )) - : null} - - - -
- ); - })} - - ); -} - -function summarizeLabels(labels: ReadonlyArray) { - return `${labels.slice(0, 2).join(", ")}${labels.length > 2 ? ` +${labels.length - 2}` : ""}`; -} diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx new file mode 100644 index 000000000000..69e23c3cdf96 --- /dev/null +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -0,0 +1,808 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Fragment, useState, type ReactNode } from "react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { + AuthSettingsWriteScope, + type DevicePlatform, + type DevicePlatformAvailability, + type DeviceServiceState, + type EasDeviceHostConfig, + type EnvironmentId, + type ServerSettingsPatch, + type SimlockDeviceHostConfig, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { ChevronDownIcon, MoreVertical, PlusIcon } from "lucide-react"; + +import { cn, randomUUID } from "../../lib/utils"; +import { deviceEnvironment } from "../../state/device"; +import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; +import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { AndroidIcon, AppleIcon } from "../Icons"; +import { Button, InlineButton } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { toastManager } from "../ui/toast"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; +import { EasDeviceHostEditor } from "./EasDeviceHostEditor"; +import { + easSessionLimits, + newEasDeviceHost, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsSection } from "./settingsLayout"; +import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis"; +import { SimlockDeviceHostEditor } from "./SimlockDeviceHostEditor"; +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockLeaseLimits, + updateSimlockDeviceHosts, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; + +const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => + deviceEnvironment.state({ environmentId, input: {} }), +); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** Something on one environment that is in progress or needs a fix; a working source has none. */ +interface SourceStatus { + readonly tone: "pending" | "warning" | "error"; + readonly label: string; + readonly detail?: string | undefined; + readonly action?: { readonly label: string; readonly run: () => void } | undefined; +} + +/** A source on one environment whose devices are on. */ +interface SourceEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly added: boolean; + readonly status?: SourceStatus | undefined; + /** Unknown until the environment's hub has reached the source. */ + readonly platforms?: ReadonlyArray | undefined; +} + +interface SourceRow { + readonly key: string; + readonly title: string; + readonly kind?: string; + readonly description: ReactNode; + readonly environments: ReadonlyArray; + /** + * One machine or account every environment reaches the same way (an SSH host, EAS, a Simlock + * pool), so its platforms are the same for all of them. Local simulators differ per machine. + */ + readonly shared: boolean; + readonly actions?: ReactNode; +} + +type Editing = + | { + readonly kind: "ssh"; + readonly host: SshDeviceHostConfig; + readonly original: SshDeviceHostConfig | null; + /** Limits the save to these environments, for a host that differs between them. */ + readonly only?: ReadonlySet; + } + | { + readonly kind: "eas"; + readonly host: EasDeviceHostConfig; + readonly isNew: boolean; + /** Where the editor checks the Expo account: an environment that holds the host. */ + readonly environmentId: EnvironmentId; + } + | { readonly kind: "simlock"; readonly host: SimlockDeviceHostConfig; readonly isNew: boolean }; + +/** What a configured host is doing on one environment, when it is not simply working. */ +function hostStatus( + state: DeviceServiceState | undefined, + hostId: string, +): SourceStatus | undefined { + if (!state) return { tone: "pending", label: "Loading" }; + const status = state.hostStatuses[hostId]; + switch (status?.status) { + case "ready": + return status.androidDiscoveryIncomplete + ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } + : undefined; + case "installing": + case "starting": + return { tone: "pending", label: "Starting", detail: status.detail }; + case "failed": + return { tone: "error", label: "Failed", detail: status.detail }; + default: + return undefined; + } +} + +const hostPlatforms = (state: DeviceServiceState | undefined, hostId: string) => + state?.hosts.find((host) => host.id === hostId)?.platforms; + +/** + * Every device source the selected environments can open, one row each. A source configured on + * several environments is one row; how it stands on each lives in the row's breakdown. + */ +export function DeviceSourcesSettings(props: { + readonly supportsEas: boolean; + readonly supportsSimlock: boolean; +}) { + const { scope, environments, connectedEnvironments, environment: primary } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const label = (environment: (typeof environments)[number]) => + settingsScopeEnvironmentLabel(environment, environments); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: label(environment), + connected: environment.connection.phase === "connected", + })); + const { testConnection } = useHostConnectionChecks(targets); + const ids = connectedEnvironments.map((environment) => environment.environmentId); + const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(ids))); + const members = connectedEnvironments.map((environment, index) => { + const result = results[index]; + return { + environmentId: environment.environmentId, + label: label(environment), + settings: environment.serverConfig?.settings, + state: result ? Option.getOrUndefined(AsyncResult.value(result)) : undefined, + }; + }); + // Environments with devices off are left out: the switch above already says so. + const active = members.filter((member) => member.settings?.enableDeviceSupport); + const simlockEnabled = members.some((member) => member.settings?.simlockDeviceHostsEnabled); + // Like every scoped setting, a change reaches the connected environments and skips the rest. + const disabled = projectScope || busy || members.length === 0; + + /** Writes one settings patch per environment; reports the environments it could not change. */ + const write = async ( + patches: ReadonlyArray<{ environmentId: EnvironmentId; patch: ServerSettingsPatch }>, + failedTitle: string, + ) => { + if (projectScope) return false; + const name = (environmentId: EnvironmentId) => + members.find((member) => member.environmentId === environmentId)?.label ?? environmentId; + const unwritable = patches.filter( + (patch) => !readEnvironmentScope(patch.environmentId, AuthSettingsWriteScope), + ); + if (unwritable.length > 0) { + toastManager.add({ + type: "error", + title: failedTitle, + description: `This connection cannot change settings on ${unwritable.map((patch) => name(patch.environmentId)).join(", ")}.`, + }); + return false; + } + setBusy(true); + const outcomes = await Promise.all( + patches.map((patch) => + update({ environmentId: patch.environmentId, input: { patch: patch.patch } }), + ), + ); + setBusy(false); + const failed = patches.flatMap((patch, index) => { + const outcome = outcomes[index]; + return outcome && outcome._tag === "Failure" + ? [`${name(patch.environmentId)}: ${failureMessage(outcome.cause)}`] + : []; + }); + if (failed.length === 0) return true; + toastManager.add({ type: "error", title: failedTitle, description: failed.join("\n") }); + return false; + }; + + const saveSsh = async ( + host: SshDeviceHostConfig, + remove: boolean, + original = host, + only?: ReadonlySet, + ) => { + try { + const saved = await write( + members + .filter((member) => !only || only.has(member.environmentId)) + .map((member) => ({ + environmentId: member.environmentId, + patch: { + deviceHosts: updateDeviceHosts( + member.settings?.deviceHosts ?? [], + host, + remove, + original, + ), + }, + })), + remove ? "Device host not removed everywhere" : "Device host not saved everywhere", + ); + if (saved) setEditing(null); + } catch (error) { + toastManager.add({ + type: "error", + title: "Device host not saved", + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const saveEas = async ( + host: EasDeviceHostConfig, + remove: boolean, + previous?: EasDeviceHostConfig, + ) => { + const keepToken = previous !== undefined && host.accessToken === previous.accessToken; + const saved = await write( + members.map((member) => ({ + environmentId: member.environmentId, + patch: { + easDeviceHosts: updateEasDeviceHosts( + member.settings?.easDeviceHosts ?? [], + host, + remove, + keepToken, + ), + }, + })), + remove ? "EAS host not removed everywhere" : "EAS host not saved everywhere", + ); + if (saved) setEditing(null); + }; + + const simlockEnvironments = members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.simlockDeviceHosts ?? [], + })); + const saveSimlock = async ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => { + const plan = planSimlockDeviceHostSave(simlockEnvironments, host, tokens); + if ("problem" in plan) return; + const saved = await write( + plan.patches.map((patch) => ({ + environmentId: patch.environmentId, + patch: { simlockDeviceHosts: patch.hosts }, + })), + "Simlock host not saved everywhere", + ); + if (saved) setEditing(null); + }; + const removeSimlock = (host: SimlockDeviceHostConfig) => + void write( + simlockEnvironments + .filter((environment) => environment.hosts.some((candidate) => candidate.id === host.id)) + .map((environment) => ({ + environmentId: environment.environmentId, + patch: { simlockDeviceHosts: updateSimlockDeviceHosts(environment.hosts, host, true) }, + })), + "Simlock host not removed everywhere", + ); + + const [testing, setTesting] = useState>(new Set()); + const testSsh = async (host: SshDeviceHostConfig) => { + setTesting((current) => new Set(current).add(host.id)); + const checked = await testConnection(host).finally(() => + setTesting((current) => { + const next = new Set(current); + next.delete(host.id); + return next; + }), + ); + if (!checked) return; + const failed = targets.filter((target) => checked[target.environmentId]?.status === "failed"); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? failed + .map((target) => { + const result = checked[target.environmentId]; + return `${target.label}: ${result?.status === "failed" ? result.error : "failed"}`; + }) + .join("\n") + : "Connected or already available locally on each selected environment.", + }); + }; + + const retry = (environmentId: EnvironmentId, hostId: string) => + void list({ environmentId, input: { retryHostId: hostId } }); + + const rowActions = (onEdit: () => void, onRemove: () => void, extra?: ReactNode) => ( + <> + + + + } + > + + + + {extra} + + Remove + + + + + ); + + const rows: SourceRow[] = []; + + rows.push({ + key: "local", + shared: false, + title: "Local simulators", + description: + members.length > 1 + ? "Simulators and emulators installed on each environment's own machine." + : "Simulators and emulators installed on this environment's machine.", + environments: active.map((member) => { + const local = member.state?.hosts.find((host) => host.kind === "local"); + const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: local?.platforms, + status: + status?.tone === "error" + ? { + ...status, + action: { + label: "Refresh", + run: () => void list({ environmentId: member.environmentId, input: {} }), + }, + } + : status, + }; + }), + }); + + const grouped = groupDeviceHosts( + members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.deviceHosts ?? [], + })), + ); + const sshGroups = + grouped ?? + // Hosts that cannot be matched across environments stay one row per environment. + members.flatMap((member) => + (member.settings?.deviceHosts ?? []).map((host) => ({ + host, + members: [{ environmentId: member.environmentId, label: member.label, host }], + })), + ); + for (const group of sshGroups) { + const host = group.host; + const holders = new Set(group.members.map((member) => member.environmentId)); + rows.push({ + key: `ssh:${host.id}:${group.members.map((member) => member.environmentId).join(",")}`, + shared: true, + title: host.label, + kind: "SSH", + description: host.target, + environments: active.map((member) => { + const held = group.members.find( + (candidate) => candidate.environmentId === member.environmentId, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + const status = testing.has(host.id) + ? ({ tone: "pending", label: "Testing connection" } as const) + : hostStatus(member.state, held.host.id); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, held.host.id), + status: + status?.tone === "error" && member.state?.supportsHostRetry + ? { + ...status, + label: "Can't connect", + action: { label: "Retry", run: () => retry(member.environmentId, held.host.id) }, + } + : status, + }; + }), + actions: rowActions( + () => + setEditing({ kind: "ssh", host, original: host, ...(grouped ? {} : { only: holders }) }), + () => void saveSsh(host, true, host, holders), + void testSsh(host)}>Test connection, + ), + }); + } + + if (props.supportsEas) { + const easHosts = new Map(); + for (const member of members) + for (const host of member.settings?.easDeviceHosts ?? []) + if (!easHosts.has(host.id)) easHosts.set(host.id, host); + for (const host of easHosts.values()) { + const holders = members.filter((member) => + (member.settings?.easDeviceHosts ?? []).some((candidate) => candidate.id === host.id), + ); + const [firstHolder] = holders; + if (!firstHolder) continue; + const checkOn = ( + holders.find((member) => member.environmentId === primary?.environmentId) ?? firstHolder + ).environmentId; + rows.push({ + key: `eas:${host.id}`, + shared: true, + title: host.label, + kind: "EAS", + description: easSessionLimits(host), + environments: active.map((member) => { + const added = (member.settings?.easDeviceHosts ?? []).some( + (candidate) => candidate.id === host.id, + ); + return { + environmentId: member.environmentId, + label: member.label, + added, + ...(added + ? { + status: hostStatus(member.state, host.id), + platforms: hostPlatforms(member.state, host.id), + } + : {}), + }; + }), + actions: rowActions( + () => setEditing({ kind: "eas", host, isNew: false, environmentId: checkOn }), + () => void saveEas(host, true), + ), + }); + } + } + + if (props.supportsSimlock && simlockEnabled) { + const simlockHosts = new Map(); + for (const environment of simlockEnvironments) + for (const host of environment.hosts) + if (!simlockHosts.has(host.id)) simlockHosts.set(host.id, host); + for (const host of simlockHosts.values()) { + const edit = () => setEditing({ kind: "simlock", host, isNew: false }); + rows.push({ + key: `simlock:${host.id}`, + shared: true, + title: host.label, + kind: "Simlock", + description: `${host.gatewayUrl.replace(/^https?:\/\//, "")} · ${simlockLeaseLimits(host)}`, + environments: active.map((member) => { + const held = (member.settings?.simlockDeviceHosts ?? []).find( + (candidate) => candidate.id === host.id, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, host.id), + status: + held.agentToken === "" + ? ({ + tone: "warning", + label: "No agent token", + ...(disabled ? {} : { action: { label: "Add token", run: edit } }), + } as const) + : hostStatus(member.state, host.id), + }; + }), + actions: rowActions(edit, () => removeSimlock(host)), + }); + } + } + + const addMenu = ( + + } + > + Add source + + + + + setEditing({ + kind: "ssh", + host: { kind: "ssh", id: randomUUID(), label: "", target: "" }, + original: null, + }) + } + > + Machine over SSH + + {props.supportsEas ? ( + + primary && + setEditing({ + kind: "eas", + host: newEasDeviceHost(randomUUID()), + isNew: true, + environmentId: primary.environmentId, + }) + } + > + EAS Simulator + + ) : null} + {props.supportsSimlock && simlockEnabled ? ( + + setEditing({ kind: "simlock", host: newSimlockDeviceHost(randomUUID()), isNew: true }) + } + > + Simlock pool (alpha) + + ) : null} + + + ); + + return ( + + {rows.map((row) => ( + + ))} + {editing?.kind === "ssh" ? ( + void saveSsh(host, false, editing.original ?? host, editing.only)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "eas" ? ( + void saveEas(host, false, editing.isNew ? undefined : editing.host)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "simlock" ? ( + void saveSimlock(host, tokens)} + onClose={() => setEditing(null)} + /> + ) : null} + + ); +} + +const PLATFORMS = [ + { platform: "ios", label: "iOS", Icon: AppleIcon }, + { platform: "android", label: "Android", Icon: AndroidIcon }, +] as const satisfies ReadonlyArray<{ platform: DevicePlatform; label: string; Icon: unknown }>; + +/** The platforms a source offers: one icon each, solid where available, muted where not. */ +function SourcePlatforms(props: { row: SourceRow }) { + const added = props.row.environments.filter((environment) => environment.added); + const known = added.filter((environment) => environment.platforms !== undefined); + return PLATFORMS.filter(({ platform }) => + known.some((environment) => + environment.platforms?.some((candidate) => candidate.platform === platform), + ), + ).map((entry) => ); +} + +function PlatformIcon(props: { + platform: DevicePlatform; + label: string; + Icon: (typeof PLATFORMS)[number]["Icon"]; + row: SourceRow; + known: ReadonlyArray; +}) { + const availability = (environment: SourceEnvironment) => + environment.platforms?.find((candidate) => candidate.platform === props.platform); + const availableOn = props.known.filter((environment) => availability(environment)?.available); + const several = props.row.environments.length > 1; + const available = availableOn.length > 0; + const partial = available && availableOn.length < props.known.length; + // A shared source usually answers the same everywhere, so its answer is given once. Each + // environment still signs in to it on its own, which is where they can differ. + const sharedAnswer = props.known.map(availability).find(Boolean); + const uniform = props.known.every( + (environment) => + availability(environment)?.available === sharedAnswer?.available && + availability(environment)?.reason === sharedAnswer?.reason, + ); + const unavailableOn = props.known.filter((environment) => !availability(environment)?.available); + const connected = props.row.environments.filter((environment) => environment.added); + const notAdded = props.row.environments.filter((environment) => !environment.added); + return ( + + + } + > + + {partial ? ( + + ) : null} + + +

+ {props.label}{" "} + {available ? (partial ? "available on some" : "available") : "not available"} +

+ {props.row.shared ? ( + <> + {uniform && !available && sharedAnswer?.reason ? ( +

{sharedAnswer.reason}

+ ) : null} + {several ? ( +
+ {( + [ + ...(uniform + ? ([["Connected", connected]] as const) + : ([ + ["Available", availableOn], + ["Not available", unavailableOn], + ] as const)), + ["Not added", notAdded], + ] as const + ).map(([heading, environments]) => + environments.length > 0 ? ( +
+
{heading}
+ {environments.map((environment) => { + const reason = + heading === "Not available" + ? availability(environment)?.reason + : undefined; + return ( +
+ {environment.label} + {reason ? ( + {reason} + ) : null} +
+ ); + })} +
+ ) : null, + )} +
+ ) : null} + + ) : several ? ( +
+ {props.known.map((environment) => { + const entry = availability(environment); + return ( + +
{environment.label}
+
+ {entry?.available ? "Available" : (entry?.reason ?? "Not available")} +
+
+ ); + })} +
+ ) : !available ? ( +

+ {availability(props.known[0]!)?.reason ?? "Not set up on this machine."} +

+ ) : null} +
+
+ ); +} + +function DeviceSourceRow(props: { row: SourceRow }) { + const { row } = props; + const several = row.environments.length > 1; + const issues = row.environments.filter( + (environment): environment is SourceEnvironment & { status: SourceStatus } => + environment.status !== undefined, + ); + const problems = issues.filter((environment) => environment.status.tone !== "pending"); + const pending = issues.find((environment) => environment.status.tone === "pending"); + return ( +
+
+
+

{row.title}

+ {row.kind ? ( + + {row.kind} + + ) : null} +
+

{row.description}

+ {problems.map((environment) => ( +

+ + {several ? `${environment.label}: ` : ""} + {!several && environment.status.detail + ? environment.status.detail + : environment.status.label} + + {environment.status.action ? ( + + {environment.status.action.label} + + ) : null} +

+ ))} +
+ {pending ? ( + + + {pending.status.label} + + ) : ( +
+ +
+ )} + {row.actions ?
{row.actions}
: null} +
+ ); +} diff --git a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx similarity index 72% rename from apps/web/src/components/settings/EasDeviceHostsSettings.tsx rename to apps/web/src/components/settings/EasDeviceHostEditor.tsx index 43c80cdb740c..220f5ffa3269 100644 --- a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,17 +3,13 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { - AuthSettingsWriteScope, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, } from "@t3tools/contracts"; -import { CloudIcon, PlusIcon, XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; -import { randomUUID } from "../../lib/utils"; import { deviceEasAccountCheck, deviceEnvironment } from "../../state/device"; -import { serverEnvironment } from "../../state/server"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { @@ -28,16 +24,8 @@ import { import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; -import { toastManager } from "../ui/toast"; -import { - easSessionLimits, - newEasDeviceHost, - parseEasDeviceHostDraft, - updateEasDeviceHosts, -} from "./easDeviceHostsSettings.logic"; +import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; import { SecretInput } from "./SecretInput"; -import { useSettingsScope } from "./SettingsScopeContext"; -import { SettingsRow } from "./settingsLayout"; const FROM_THREAD_PROJECT = "thread-project"; @@ -52,116 +40,13 @@ const failureMessage = (cause: Cause.Cause) => { return error instanceof Error ? error.message : String(error); }; -/** EAS hosts belong to one environment: its server holds the Expo credential and runs the sessions. */ -export function EasDeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const environment = connectedEnvironments.find( - (candidate) => candidate.environmentId === props.environmentId, - ); - const hosts = environment?.serverConfig?.settings.easDeviceHosts ?? []; - const [editing, setEditing] = useState<{ host: EasDeviceHostConfig; isNew: boolean } | null>( - null, - ); - const [busy, setBusy] = useState(false); - const disabled = !canConfigure || projectScope || busy || !props.environmentId; - - const save = async (host: EasDeviceHostConfig, remove: boolean) => { - if (!props.environmentId || !readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) - return; - setBusy(true); - const result = await update({ - environmentId: props.environmentId, - input: { patch: { easDeviceHosts: updateEasDeviceHosts(hosts, host, remove) } }, - }); - setBusy(false); - if (result._tag === "Success") setEditing(null); - else - toastManager.add({ - type: "error", - title: remove ? "EAS host not removed" : "EAS host not saved", - description: failureMessage(result.cause), - }); - }; - - return ( - setEditing({ host: newEasDeviceHost(randomUUID()), isNew: true })} - > - Add EAS host - - } - > -
- {hosts.length === 0 ? ( -

No EAS hosts.

- ) : ( -
    - {hosts.map((host) => ( -
  • -
    -

    - {host.label} -

    -

    {easSessionLimits(host)}

    -
    -
    - - -
    -
  • - ))} -
- )} - {editing && props.environmentId ? ( - void save(host, false)} - onClose={() => setEditing(null)} - /> - ) : null} -
-
- ); -} - type AccountCheck = | { status: "idle" } | { status: "checking" } | { status: "signed-in"; account: DeviceEasAccount } | { status: "failed"; error: string }; -function EasDeviceHostEditor(props: { +export function EasDeviceHostEditor(props: { environmentId: EnvironmentId; host: EasDeviceHostConfig; isNew: boolean; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 58e5d6877c82..5239f57bfdc3 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -2,8 +2,7 @@ import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; -import { DeviceHostsSettings } from "./DeviceHostsSettings"; -import { EasDeviceHostsSettings } from "./EasDeviceHostsSettings"; +import { DeviceSourcesSettings } from "./DeviceSourcesSettings"; import { AuthSettingsWriteScope, BrowserImportFailureReason, @@ -42,7 +41,6 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; -import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; @@ -58,11 +56,6 @@ import { AgentDeviceSetupStatus, DeviceHubSetupStatus, deviceHubReadiness, - PlatformStatus, - platformSetupStatus, - deviceHubDescription, - agentDeviceDescription, - DeviceHostDiagnostics, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; @@ -797,15 +790,23 @@ function DeviceIntegrationSettings() { const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; + const { state } = useDeviceState(environmentId); + return ( - - + + + + - + ); } @@ -831,20 +832,7 @@ function DeviceIntegrationControls({ const [pending, setPending] = useState< "hub" | "check" | "agent" | "update-hub" | "update-agent" | null >(null); - const { ready: hubReady, busy } = deviceHubReadiness(state); - const localPlatformsUnavailable = state.hosts.some( - (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), - ); - const [platformsRevealed, setPlatformsRevealed] = useState(false); - if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if ( - enabled && - !platformsRevealed && - (hubReady || localPlatformsUnavailable) && - pending !== "hub" - ) { - setPlatformsRevealed(true); - } + const { busy } = deviceHubReadiness(state); const update = async ( kind: NonNullable, @@ -941,14 +929,18 @@ function DeviceIntegrationControls({ {...searchableSetting("device-hub")} serverScoped settingKeys={["enableDeviceSupport"]} - description={deviceHubDescription} - control={ + description={ <> + Open simulators and emulators in the Device panel. Hub:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "hub" ? : null} } /> - - {platformsRevealed ? ( - 1 - ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` - : undefined - } - status={ -
-
- - -
- {/* Remote hosts report their own warnings in the device host list below. */} - host.kind === "local")} - hostStatuses={state.hostStatuses} - /> -
- } - control={ - - } - /> - ) : null} -
+ New agent sessions can open and drive these devices. agent-device:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "agent" ? : null} ) : null} - - {state.supportsEasHosts ? : null} ); } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 15c943728f14..ce3f68eac49d 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -21,6 +21,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -148,3 +149,43 @@ function LinkedEnvironmentsRow() { /> ); } + +function SimlockHostsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + Let the Device panel lease simulators and emulators from your own{" "} +
+ Simlock + {" "} + pool, added in{" "} + + Devices + + . An alpha that may change or break. T3 Code never installs Simlock. + + } + control={ + + updateSettings({ simlockDeviceHostsEnabled: Boolean(checked) }) + } + aria-label="Simlock device hosts" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx new file mode 100644 index 000000000000..7564d86e67a2 --- /dev/null +++ b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx @@ -0,0 +1,224 @@ +import { useId, useState, type ReactNode } from "react"; +import type { EnvironmentId, SimlockDeviceHostConfig } from "@t3tools/contracts"; +import { XIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { SecretInput } from "./SecretInput"; +import { + planSimlockDeviceHostSave, + type SimlockHostEnvironment, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; + +/** Shared fields once, then one agent token per environment: Simlock counts leases per token. */ +export function SimlockDeviceHostEditor(props: { + host: SimlockDeviceHostConfig; + isNew: boolean; + environments: ReadonlyArray; + busy: boolean; + onSave: ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(props.host); + const savedToken = (environment: SimlockHostEnvironment) => + environment.hosts.some((host) => host.id === props.host.id && host.agentToken !== ""); + const [tokens, setTokens] = useState>( + () => + new Map( + props.environments.map((environment) => [ + environment.environmentId, + savedToken(environment) ? { kind: "saved" } : { kind: "cleared" }, + ]), + ), + ); + const next: SimlockDeviceHostConfig = { + ...draft, + label: draft.label.trim(), + gatewayUrl: draft.gatewayUrl.trim(), + }; + const plan = planSimlockDeviceHostSave(props.environments, next, tokens); + const problem = "problem" in plan ? plan.problem : null; + const several = props.environments.length > 1; + + const limit = ( + field: "maxLeases" | "maxLeaseMinutes", + label: string, + min: number, + max: number, + ) => ( + + ); + + return ( + { + if (!open && !props.busy) props.onClose(); + }} + > + +
{ + event.preventDefault(); + if (problem === null && !props.busy) props.onSave(next, tokens); + }} + > + + {props.isNew ? "Add Simlock host" : "Edit Simlock host"} + + {several + ? "Each selected environment's server leases devices from your Simlock gateway with its own agent token, and releases them when they close." + : "The environment's server leases devices from your Simlock gateway and releases them when they close."} + + + + + +
+ {props.environments.map((environment) => ( + + setTokens((current) => new Map(current).set(environment.environmentId, token)) + } + /> + ))} +

+ Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} + with simlock --gateway token create --role agent. +

+
+
+ {limit("maxLeases", "Devices at once", 1, 20)} + {limit("maxLeaseMinutes", "Lease minutes", 5, 240)} +
+

+ A lease ends after the lease minutes even while in use. + {props.isNew ? null : " Saving restarts this host, which releases its devices."} +

+ {problem === null || !draft.gatewayUrl ? null : ( +

+ {problem} +

+ )} +
+ + + + +
+
+
+ ); +} + +function SimlockTokenField(props: { + label: ReactNode; + token: SimlockTokenEntry; + hasSaved: boolean; + busy: boolean; + onChange: (token: SimlockTokenEntry) => void; +}) { + const id = useId(); + return ( +
+ +
+ { + const value = event.target.value; + // Emptying the field is not clearing the saved token; the Clear + // button is, so a saved token comes back here. + props.onChange( + value + ? { kind: "new", value } + : props.hasSaved + ? { kind: "saved" } + : { kind: "cleared" }, + ); + }} + /> + {props.token.kind !== "cleared" ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts index 545d1ac212ec..c3105b115e21 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Option from "effect/Option"; -import { newEasDeviceHost, parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; +import { + newEasDeviceHost, + parseEasDeviceHostDraft, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; describe("EAS device host settings", () => { it("rejects limits EAS does not allow", () => { @@ -18,4 +22,15 @@ describe("EAS device host settings", () => { true, ); }); + + it("keeps each environment's own token when a save leaves the token unchanged", () => { + const edited = { ...newEasDeviceHost("eas"), label: "Renamed", accessToken: "" }; + const elsewhere = { ...newEasDeviceHost("eas"), accessToken: "••••••" }; + expect(updateEasDeviceHosts([elsewhere], edited, false, true)).toEqual([ + { ...edited, accessToken: "••••••" }, + ]); + expect(updateEasDeviceHosts([], { ...edited, accessToken: "••••••" }, false, true)).toEqual([ + { ...edited, accessToken: "" }, + ]); + }); }); diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts index 418a15d0064e..d91fe45bb920 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts @@ -17,15 +17,22 @@ export const newEasDeviceHost = (id: string): EasDeviceHostConfigType => ({ }); /** Adds, replaces, or removes one EAS host. */ +/** + * With `keepToken`, the environment keeps the token it holds: a saved token arrives redacted, and + * one environment's token says nothing about another's. One new to the host uses its own Expo login. + */ export function updateEasDeviceHosts( hosts: ReadonlyArray, host: EasDeviceHostConfigType, remove: boolean, + keepToken = false, ): ReadonlyArray { if (remove) return hosts.filter((candidate) => candidate.id !== host.id); - return hosts.some((candidate) => candidate.id === host.id) - ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) - : [...hosts, host]; + const existing = hosts.find((candidate) => candidate.id === host.id); + const next = keepToken ? { ...host, accessToken: existing?.accessToken ?? "" } : host; + return existing + ? hosts.map((candidate) => (candidate.id === host.id ? next : candidate)) + : [...hosts, next]; } export function easSessionLimits(host: EasDeviceHostConfigType) { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 325d0c1ba75e..36f63bf64ba8 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -451,6 +451,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "simlock-hosts", + title: "Simlock device hosts (alpha)", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta alpha simlock callstack simulator emulator ios android pool lease gateway"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", @@ -806,31 +813,33 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["allow disable enable open drive preview tools sessions project override"], }, { - id: "device-hosts", - title: "Device hosts", + id: "device-sources", + title: "Device sources", to: "/settings/devices", - searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + searchTerms: [ + "device hosts ssh remote mac mini identity key connection eas expo cloud simlock pool gateway local simulators", + ], }, { id: "agent-device-access", - title: "Agent device access", + title: "Agents can use devices", to: "/settings/devices", targetId: "devices", searchTerms: ["allow simulator emulator ios android drive tools sessions"], }, { id: "device-hub", - title: "Device hub", + title: "Use devices", to: "/settings/devices", targetId: "devices", - searchTerms: ["simulator emulator ios android install start"], + searchTerms: ["device hub simulator emulator ios android install start version"], }, { id: "device-platform-support", - title: "Simulator support", + title: "Local simulators", to: "/settings/devices", - targetId: "devices", - searchTerms: ["xcode android studio sdk avd runtime"], + targetId: "device-sources", + searchTerms: ["simulator support xcode android studio sdk avd runtime"], }, { id: "browser-profiles", diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts new file mode 100644 index 000000000000..752ab616bdb5 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockDeviceHostProblem, + updateSimlockDeviceHosts, +} from "./simlockDeviceHostsSettings.logic"; + +const host = { + ...newSimlockDeviceHost("simlock"), + gatewayUrl: "https://gateway.example.ts.net:4700", + agentToken: "agent", +}; + +describe("Simlock device host settings", () => { + it("names what keeps a draft from saving", () => { + expect(simlockDeviceHostProblem(host)).toBeNull(); + expect(simlockDeviceHostProblem({ ...host, label: " " })).toBe("Give the host a label."); + expect(simlockDeviceHostProblem({ ...host, gatewayUrl: "gateway:4700" })).toMatch(/origin/); + expect( + simlockDeviceHostProblem({ ...host, gatewayUrl: "https://gateway.example/api" }), + ).toMatch(/origin/); + expect(simlockDeviceHostProblem({ ...host, maxLeases: 21 })).toMatch(/ranges/); + expect(simlockDeviceHostProblem({ ...host, maxLeaseMinutes: 4 })).toMatch(/ranges/); + }); + + it("adds, replaces, and removes one host", () => { + const other = { ...host, id: "other", label: "Other" }; + const added = updateSimlockDeviceHosts([other], host, false); + expect(added.map((candidate) => candidate.id)).toEqual(["other", "simlock"]); + const renamed = updateSimlockDeviceHosts(added, { ...host, label: "Renamed" }, false); + expect(renamed.map((candidate) => candidate.label)).toEqual(["Other", "Renamed"]); + expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); + }); + + it("saves the shared fields everywhere and each environment's own token", () => { + const mbp = "mbp" as EnvironmentId; + const cups = "cups" as EnvironmentId; + const environments = [ + { environmentId: mbp, label: "mbp", hosts: [{ ...host, agentToken: "" }] }, + { environmentId: cups, label: "cups", hosts: [] }, + ]; + const draft = { ...host, label: "Pool", agentToken: "" }; + + expect( + planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), + ).toEqual({ + problem: "Add an agent token for cups.", + }); + expect( + planSimlockDeviceHostSave( + environments, + draft, + new Map([ + [mbp, { kind: "saved" }], + [cups, { kind: "new", value: " cups-token " }], + ]), + ), + ).toEqual({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "cups-token" }] }, + ], + }); + }); +}); diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts new file mode 100644 index 000000000000..3a2f65b1d906 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -0,0 +1,94 @@ +import { + SimlockDeviceHostConfig, + type EnvironmentId, + type SimlockDeviceHostConfig as SimlockDeviceHostConfigType, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const parseSimlockDeviceHostDraft = Schema.decodeUnknownOption(SimlockDeviceHostConfig); + +export const newSimlockDeviceHost = (id: string): SimlockDeviceHostConfigType => ({ + kind: "simlock", + id, + label: "Simlock", + gatewayUrl: "", + agentToken: "", + maxLeases: 2, + maxLeaseMinutes: 60, +}); + +/** Adds, replaces, or removes one Simlock host. */ +export function updateSimlockDeviceHosts( + hosts: ReadonlyArray, + host: SimlockDeviceHostConfigType, + remove: boolean, +): ReadonlyArray { + if (remove) return hosts.filter((candidate) => candidate.id !== host.id); + return hosts.some((candidate) => candidate.id === host.id) + ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) + : [...hosts, host]; +} + +/** Why the shared fields keep a draft from saving, or null; tokens are checked per environment. */ +export function simlockDeviceHostProblem(host: SimlockDeviceHostConfigType): string | null { + const withToken = { ...host, agentToken: "token" }; + if (Option.isSome(parseSimlockDeviceHostDraft(withToken))) return null; + if (!host.label.trim()) return "Give the host a label."; + // With in-range limits, a draft that still fails has a bad gateway URL. + return Option.isNone( + parseSimlockDeviceHostDraft({ ...withToken, maxLeases: 1, maxLeaseMinutes: 60 }), + ) + ? "Enter the gateway's origin, such as https://gateway.example.ts.net:4700." + : "Keep the limits within the ranges shown."; +} + +export function simlockLeaseLimits(host: SimlockDeviceHostConfigType) { + const devices = host.maxLeases === 1 ? "1 device" : `${host.maxLeases} devices`; + return `Up to ${devices} at once, each released after ${host.maxLeaseMinutes} min`; +} + +export interface SimlockHostEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly hosts: ReadonlyArray; +} + +/** A token field: the environment's saved token, a replacement, or none. */ +export type SimlockTokenEntry = + | { kind: "saved" } + | { kind: "new"; value: string } + | { kind: "cleared" }; + +/** + * The host list each environment saves: the shared fields everywhere, and each environment's own + * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + */ +export function planSimlockDeviceHostSave( + environments: ReadonlyArray, + draft: SimlockDeviceHostConfigType, + tokens: ReadonlyMap, +): + | { readonly problem: string } + | { + readonly patches: ReadonlyArray<{ + environmentId: EnvironmentId; + hosts: ReadonlyArray; + }>; + } { + const shared = simlockDeviceHostProblem(draft); + if (shared) return { problem: shared }; + const patches = []; + for (const environment of environments) { + const saved = environment.hosts.find((host) => host.id === draft.id)?.agentToken ?? ""; + const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; + const agentToken = + entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; + if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; + patches.push({ + environmentId: environment.environmentId, + hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), + }); + } + return { patches }; +} diff --git a/docs/internals/devices.md b/docs/internals/devices.md index c50cb504a84e..5273f7942e9e 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -44,6 +44,27 @@ clients never hold it. Agent config files are written per device device before targeting it. Device settings run `simctl` on the simulator's machine, which EAS does not expose, so they are unavailable there. +## Simlock hosts lease through the agent-device front + +A [Simlock host](../../apps/server/src/device/SimlockDeviceHost.ts) (alpha) opens a +device by running T3's own agent-device CLI against the gateway's agent-device +front (`connect proxy`, then `open`), not with Simlock's lease HTTP API: +agent-device cannot adopt a lease it did not allocate. The lease is bound to +that one agent-device session and to the directory `open` ran in, so +`agentEndpoint` hands every thread the same session, a `stateDir` holding its +connection record, and no `--udid`/`--serial` (the gateway refuses selectors). +Installs work only from that directory; `device_open` names it. + +Simlock serves a lease's screen and input to its holder in serve-sim's formats +on both platforms: AVCC envelopes at `/v1/leases//stream` and serve-sim's +input messages at `/v1/leases//input`, both with the agent token. Clients +therefore treat a Simlock Android lease like iOS (`serveSimOnly` in the stream +client), and `upstream` maps serve-sim's `stream.avcc` and `helper/ws` paths to +those routes. Simlock has no MJPEG, screenshot, or host command routes, so the +panel offers only input there. +Leases are renewed while tracked and released at the host's age limit; one a +crashed server left behind ends on its own one TTL later, so there is no ledger. + ## The hub is never exposed serve-sim has a shell-exec route whose token is readable from its own diff --git a/docs/user/devices.md b/docs/user/devices.md index 42c0db4b27af..ce5a7d8d06d0 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -22,7 +22,7 @@ Choose **Float device over chat** in the toolbar to keep watching and tapping th device in a small window while the right panel shows something else; drag the window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. -Turn off the device hub in **Settings → Devices** to stop the +Turn off **Use devices** in **Settings → Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -80,12 +80,12 @@ viewer stops streaming and leaves the device available to the agent. Device activity also appears in the thread timeline. Agents drive the device through the `agent-device` command line. T3 -Code installs and starts it only after **Agent device access** is enabled. iOS +Code installs and starts it only after **Agents can use devices** is on. iOS taps build a small test runner on first use, which takes a couple of minutes once per server. Restart an existing agent session after granting access so it receives the device CLI environment. -To keep agents away from simulators, turn off **Agent device access** in +To keep agents away from simulators, turn off **Agents can use devices** in **Settings → Devices**. This hides the device tools from agents started from then on; your own Device panel is unaffected. @@ -99,12 +99,12 @@ still-image stream and Android cannot show video. ## SSH device hosts In Settings → Devices, choose the environments that should use -the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with +the host and choose **Add source → Machine over SSH**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. -**Test connection** checks SSH, Node, npm, and platform tools without installing +**Test connection**, in the host's menu, checks SSH, Node, npm, and platform tools without installing anything, with a result for each selected environment. Targets that resolve to the environment’s own machine are skipped, since its devices are already local. The first device listing installs pinned device tools on the host. @@ -123,8 +123,45 @@ installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. +## Simlock hosts (alpha) + +[Simlock](https://incubator.callstack.com/simlock/) pools iOS simulators and +Android emulators across machines. If you run a Simlock gateway, T3 can lease +devices from it. Simlock is new and unreleased, so this integration is an +alpha: it may change or stop working with a newer Simlock. T3 does not install +or manage Simlock; set up the gateway and its workers yourself. It needs a +gateway and workers built from Simlock's `main` at +[6316e10](https://github.com/callstackincubator/simlock/commit/6316e10) or +later, the first with a lease holder's screen and input. + +Turn on **Settings → Labs → Simlock device hosts (alpha)** and **Use devices** +in Settings → Devices, then choose **Add source → Simlock pool** with an **agent token** +(`simlock --gateway token create --role agent`). Give each T3 server its own +token: Simlock counts every lease made with one token against the same +requester, and allows one at a time unless the gateway's +`lease.maxPerRequester` is raised to the host's **Devices at once**. The token +leases devices and reaches only its own leases, but commands on a leased device +run on its worker, so treat it like shell access to your workers. T3 sends it +with every request, so use an HTTPS gateway URL, or plain HTTP only over an +encrypted network such as Tailscale. The gateway +and workers need `capabilities.stream.enabled` and `capabilities.input.enabled` +for the live screen and for input. + +The picker lists the models your workers can make. Opening one takes a lease; +closing it with **Return device to the pool**, or removing the host, returns it +wiped. The panel keeps a watched device leased up to the host's lease limit. +You can tap, swipe, type, and press Home, and Back and Recents on Android. +Rotation, appearance, text size, device tools, and screenshots are not offered +for Simlock devices; agents can still do them through `device_open`. On iOS 27 +simulators, typing on your keyboard does nothing; tap the on-screen keyboard +instead. Workers must run the same agent-device +version as T3 (see Device tool updates), and agent-device accepts app installs +for a lease only from the folder it was opened from, which `device_open` +names. A device cannot reach development servers on your machine through +localhost; use an address its worker can reach. + ## Device tool updates -The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Devices → Check versions reads installed versions without installing tools or starting devices. +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. In Settings → Devices, select a version and choose **Check versions** to read installed versions without installing tools or starting devices. To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index 7da96cb3d75b..737daf5158c0 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -199,7 +199,11 @@ class MjpegImage extends EventTarget { } } -function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true) { +function recoveryFixture( + platform: "ios" | "android" = "ios", + preferMjpeg = true, + serveSimOnly = false, +) { vi.useFakeTimers(); const sockets: Socket[] = []; class Socket { @@ -211,7 +215,9 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true onclose: ((event: { code: number; reason: string }) => void) | null = null; send = vi.fn(); close = vi.fn(); - constructor() { + readonly url: string; + constructor(url: string) { + this.url = url; sockets.push(this); } } @@ -273,6 +279,7 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true { platform, preferMjpeg, + serveSimOnly, deviceId: "test-device", access: { httpBase: "https://test/api/device-hub", @@ -353,6 +360,56 @@ describe("shared device stream readiness and recovery", () => { } }); + it("streams and drives a serve-sim-only Android device over serve-sim's AVCC and input socket", async () => { + const { client, events, fetch, sockets, videoBodies, decoders } = recoveryFixture( + "android", + false, + true, + ); + client.start(); + await vi.advanceTimersByTimeAsync(0); + // No MJPEG priming: Simlock serves only the AVCC stream and the input socket. + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://test/api/device-hub/vendor/serve-sim/helper/test-device/stream.avcc", + ]); + expect(sockets.map((socket) => socket.url)).toEqual([ + "wss://test/api/device-hub/vendor/serve-sim/helper/ws?device=test-device", + ]); + // Simlock adds meta (5), keep-alive (6), and lease-end (7) frames to serve-sim's envelopes. + videoBodies[0]!.enqueue( + new Uint8Array([ + ...envelope(5, [0x7b, 0x7d]), + ...envelope(1, [1, 0x64, 0, 0x1f]), + ...envelope(6, []), + ...envelope(2, [2]), + ...envelope(7, [0x7b, 0x7d]), + ]), + ); + await vi.advanceTimersByTimeAsync(0); + expect(decoders[0]!.decode).toHaveBeenCalledOnce(); + + const input = sockets[0]!; + input.onmessage?.({ data: Uint8Array.of(0x83).buffer }); + input.onmessage?.({ + data: new Uint8Array([ + 0x82, + ...new TextEncoder().encode('{"width":1080,"height":2400,"orientation":"portrait"}'), + ]).buffer, + }); + expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined); + client.sendTouch("begin", 0.25, 0.5); + client.pressButton("back"); + const sent = input.send.mock.calls.map(([payload]) => { + const bytes = payload as Uint8Array; + return [bytes[0], JSON.parse(new TextDecoder().decode(bytes.subarray(1)))]; + }); + expect(sent).toEqual([ + [0x03, { type: "begin", x: 0.25, y: 0.5 }], + [0x04, { button: "back" }], + ]); + client.stop(); + }); + it("does not repaint a delayed JPEG seed over newer decoded video", async () => { const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false); let resolveBitmap!: (bitmap: ImageBitmap) => void; diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index dc1720725f4f..c4b807859724 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -120,6 +120,8 @@ export interface DeviceStreamTarget { readonly preferMjpeg?: boolean; readonly panelId?: 1 | 3; readonly videoOnly?: boolean; + /** The host speaks only serve-sim's AVCC stream and input socket, on both platforms (Simlock). */ + readonly serveSimOnly?: boolean; } export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; @@ -388,12 +390,16 @@ export function createDeviceStreamClient( ): DeviceStreamClient { const { access, platform, deviceId } = target; const sink = "present" in output ? output : createCanvasFrameSink(output); - const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + // Android normally streams over serve-emu's socket; a serve-sim-only host serves it like iOS. + const serveSimOnly = target.serveSimOnly === true; + const avcc = platform === "ios" || serveSimOnly; + const vendor = avcc ? "/vendor/serve-sim" : "/vendor/serve-emu"; const device = encodeURIComponent(deviceId); const httpUrl = (path: string) => withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access); const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access); - const useWebCodecs = isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg); + const useWebCodecs = + isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg && !serveSimOnly); let stopped = true; let socket: WebSocket | null = null; @@ -551,7 +557,7 @@ export function createDeviceStreamClient( const paint = (source: CanvasImageSource, width: number, height: number) => { if (stopped) return; - if (platform === "android" && (screen?.width !== width || screen.height !== height)) { + if (!avcc && (screen?.width !== width || screen.height !== height)) { screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" }; events.onScreen(screen); } @@ -572,7 +578,11 @@ export function createDeviceStreamClient( }; const recoverDecoder = () => { - if (platform === "ios") fallBackToMjpeg(); + if (serveSimOnly) { + // A new AVCC request starts a fresh capture with a keyframe; there is no MJPEG. + closeDecoder(); + controller?.abort(); + } else if (platform === "ios") fallBackToMjpeg(); else { closeDecoder(); connecting("Video decoder restarted."); @@ -585,10 +595,7 @@ export function createDeviceStreamClient( const decoder = new VideoDecoder({ output: (frame) => { try { - if ( - videoDecoder === decoder && - (platform !== "ios" || feedGeneration === videoGeneration) - ) { + if (videoDecoder === decoder && (!avcc || feedGeneration === videoGeneration)) { decodedVideoGeneration = feedGeneration; paint(frame, frame.displayWidth, frame.displayHeight); } @@ -597,11 +604,7 @@ export function createDeviceStreamClient( } }, error: () => { - if ( - stopped || - videoDecoder !== decoder || - (platform === "ios" && feedGeneration !== videoGeneration) - ) + if (stopped || videoDecoder !== decoder || (avcc && feedGeneration !== videoGeneration)) return; recoverDecoder(); }, @@ -638,7 +641,7 @@ export function createDeviceStreamClient( awaitingKeyframe = false; } if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { - if (platform !== "ios") { + if (!avcc) { recoverDecoder(); return; } @@ -769,7 +772,12 @@ export function createDeviceStreamClient( if (!configured) { await reader.cancel().catch(() => {}); if (!isCurrent()) return; - if (target.videoOnly) + if (serveSimOnly) + setStatus( + "error", + `This browser cannot decode this device's ${avcCodecString(chunk.payload)} stream.`, + ); + else if (target.videoOnly) setStatus( "error", `This browser cannot decode the Duo panel's ${avcCodecString(chunk.payload)} stream.`, @@ -1070,7 +1078,7 @@ export function createDeviceStreamClient( const connectIosInput = async () => { if (stopped) return; const session = generation; - await primeIosHelper(session); + if (!serveSimOnly) await primeIosHelper(session); if (stopped || generation !== session) return; const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); ws.binaryType = "arraybuffer"; @@ -1231,7 +1239,11 @@ export function createDeviceStreamClient( iosInputAdmitted = false; iosInputUnavailable = false; connecting(); - if (platform === "ios") { + if (serveSimOnly) { + if (!target.videoOnly) void connectIosInput(); + if (useWebCodecs) void readIosVideo(); + else fail("This browser cannot decode this device's stream (WebCodecs unavailable)."); + } else if (platform === "ios") { if (!target.videoOnly) void connectIosInput(); if (useWebCodecs) void readIosVideo(); else fallBackToMjpeg(); @@ -1275,7 +1287,7 @@ export function createDeviceStreamClient( const send = (payload: Uint8Array | string) => { if ( !stopped && - (platform !== "ios" || (iosInputAdmitted && !iosInputUnavailable)) && + (!avcc || (iosInputAdmitted && !iosInputUnavailable)) && socket?.readyState === WebSocket.OPEN ) socket.send(payload); @@ -1326,7 +1338,7 @@ export function createDeviceStreamClient( startDuoVideo(panels); }, sendTouch: (phase, x, y) => { - if (platform === "ios") { + if (avcc) { send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) })); return; } @@ -1334,7 +1346,7 @@ export function createDeviceStreamClient( send(JSON.stringify({ type: "touch", action, x, y })); }, sendKey: (event, phase) => { - if (platform === "ios") { + if (avcc) { const usage = hidUsageForCode(event.code); if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage })); return; @@ -1348,7 +1360,7 @@ export function createDeviceStreamClient( } }, sendText: (text) => { - if (platform !== "android") return; + if (avcc) return; let chunk = ""; let chunkBytes = 0; let sentChunks = 0; @@ -1366,15 +1378,17 @@ export function createDeviceStreamClient( if (chunk.length > 0) send(JSON.stringify({ type: "text", text: chunk })); }, pressButton: (button) => { - if (platform === "ios") { + if (avcc) { const name = button === "home" ? "home" - : button === "appSwitcher" + : button === "appSwitcher" || button === "recents" ? "app_switcher" : button === "power" ? "lock" - : null; + : button === "back" && platform === "android" + ? "back" + : null; if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name })); return; } diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index 6de9f15125d4..e49e54a09fdf 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -4,6 +4,7 @@ import { DeviceBootError, DeviceServiceState, EasDeviceHostConfig, + SimlockDeviceHostConfig, EasDeviceHostConfigs, SshDeviceHostConfigs, } from "./device.ts"; @@ -49,6 +50,7 @@ describe("configured device hosts", () => { it("marks the EAS access token as a secret setting", () => { expect(secretSettingsFields(EasDeviceHostConfig)).toEqual(["accessToken"]); + expect(secretSettingsFields(SimlockDeviceHostConfig)).toEqual(["agentToken"]); }); }); @@ -63,6 +65,7 @@ describe("device state for clients from before EAS hosts", () => { hosts: [ { ...summary, id: "local", kind: "local" as const }, { ...summary, id: "eas", kind: "eas" as const }, + { ...summary, id: "pool", kind: "simlock" as const }, ], hostStatus: "ready" as const, hostStatuses: {}, @@ -74,7 +77,7 @@ describe("device state for clients from before EAS hosts", () => { revision: 1, }; - it("sends EAS hosts where an older client does not look, and reads them back", () => { + it("sends EAS and Simlock hosts where an older client does not look, and reads them back", () => { const wire = encodeState(state); expect(decodeOlderState(wire).hosts.map((host) => host.id)).toEqual(["local"]); expect(decodeState(wire).hosts).toEqual(state.hosts); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index dbb90f6d41e5..2335d6ba8d65 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -161,7 +189,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +234,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +259,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +273,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c6a59445d66f..dda56d86bae6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { EasDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; +import { EasDeviceHostConfigs, SimlockDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -1195,6 +1195,9 @@ export const ServerSettings = Schema.Struct({ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Alpha: Simlock device hosts. Off, the server ignores `simlockDeviceHosts`. */ + simlockDeviceHostsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1551,6 +1554,8 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), + simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs), + simlockDeviceHostsEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads), idleCompaction: Schema.optionalKey(IdleCompactionMode), From ba18706bcec1968ceea22bad11d8d24a4587fb0f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Oct 2026 12:20:40 -0700 Subject: [PATCH 10/38] fix(mobile): use a native split button for Android merges (#26373) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/mobile/assets/icons/chevron-down.xml | 4 + .../src/components/ui/MenuPopup.android.tsx | 11 +- .../src/components/ui/SplitButton.android.tsx | 145 ++++++ apps/mobile/src/components/ui/SplitButton.tsx | 65 +++ .../src/components/ui/SplitButton.types.ts | 14 + .../PullRequestMergeControls.tsx | 143 ++---- patches/@expo__ui@58.0.20.patch | 472 +++++++++++++++++- pnpm-lock.yaml | 8 +- 8 files changed, 737 insertions(+), 125 deletions(-) create mode 100644 apps/mobile/assets/icons/chevron-down.xml create mode 100644 apps/mobile/src/components/ui/SplitButton.android.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.types.ts diff --git a/apps/mobile/assets/icons/chevron-down.xml b/apps/mobile/assets/icons/chevron-down.xml new file mode 100644 index 000000000000..285eda9c1b46 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index b5af18cd2bcb..dd6ef2b04656 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -55,7 +55,9 @@ function MenuIcon(props: { ); } -export function MenuPopup(props: MenuPopupProps) { +export function MenuPopupItems( + props: Pick, +) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); const body = resolveScaledTextRole("body", appearance.baseFontSize); @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) { const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; - const items = ( + return ( <> {props.parent ? ( @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) { ))} ); +} + +export function MenuPopup(props: MenuPopupProps) { + const { themeVariables: colors } = useAppearancePreferences(); + const items = ; if (props.inline) { return ( ; + const enabled = !props.disabled && !props.loading; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + const buttonColors = { + containerColor: colors["--color-primary"], + contentColor: colors["--color-primary-foreground"], + disabledContainerColor: colors["--color-subtle-strong"], + disabledContentColor: colors["--color-foreground-muted"], + }; + // A usable menu beside a disabled action still takes the disabled palette, so the pair reads as one control. + const trailingColors = enabled + ? buttonColors + : { + ...buttonColors, + containerColor: buttonColors.disabledContainerColor, + contentColor: buttonColors.disabledContentColor, + }; + const label = ( + + {props.loading ? ( + <> + + + + ) : props.icon ? ( + <> + {/* The glyph host consumes touches, so it runs the action itself. */} + + + + + + + + ) : null} + {props.label} + + ); + return ( + + + + {label} + + + setExpanded(false)} + color={colors["--color-card-alt"]} + > + + + + + { + setExpanded(false); + if (action.id !== undefined) props.onPressAction(action.id); + }} + onBack={() => {}} + /> + + + + + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.tsx b/apps/mobile/src/components/ui/SplitButton.tsx new file mode 100644 index 000000000000..511c3a5b7c29 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.tsx @@ -0,0 +1,65 @@ +import { Pressable, View } from "react-native"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { Menu } from "./Menu"; +import type { SplitButtonProps } from "./SplitButton.types"; + +export function SplitButton(props: SplitButtonProps) { + const active = !props.disabled; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + return ( + + + {props.loading ? ( + + ) : props.icon ? ( + + ) : null} + + {props.label} + + + {props.actions.length > 0 ? ( + + props.onPressAction(nativeEvent.event)} + > + + + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.types.ts b/apps/mobile/src/components/ui/SplitButton.types.ts new file mode 100644 index 000000000000..73d0b36f2ce2 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.types.ts @@ -0,0 +1,14 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { AppSymbolName } from "./AppSymbol"; + +export interface SplitButtonProps { + readonly label: string; + readonly accessibilityLabel?: string; + readonly icon?: AppSymbolName; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly onPress: () => void; + readonly menuTitle: string; + readonly actions: MenuAction[]; + readonly onPressAction: (id: string) => void; +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb42..8ba68d2a2bfa 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { SplitButton } from "../../components/ui/SplitButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,8 +23,8 @@ import { import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { PullRequestStackState } from "./usePullRequestStack"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +166,41 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - - ) : null} + {status ?

{status}

: null} ); } diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts index 752ab616bdb5..2efe3c78c24e 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -36,7 +36,7 @@ describe("Simlock device host settings", () => { expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); }); - it("saves the shared fields everywhere and each environment's own token", () => { + it("saves the shared fields everywhere and each environment's own token, if it has one", () => { const mbp = "mbp" as EnvironmentId; const cups = "cups" as EnvironmentId; const environments = [ @@ -48,7 +48,10 @@ describe("Simlock device host settings", () => { expect( planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), ).toEqual({ - problem: "Add an agent token for cups.", + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "" }] }, + ], }); expect( planSimlockDeviceHostSave( diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts index 3a2f65b1d906..a4ef1e1bb90b 100644 --- a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -62,7 +62,7 @@ export type SimlockTokenEntry = /** * The host list each environment saves: the shared fields everywhere, and each environment's own - * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + * token, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it. */ export function planSimlockDeviceHostSave( environments: ReadonlyArray, @@ -84,7 +84,6 @@ export function planSimlockDeviceHostSave( const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; const agentToken = entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; - if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; patches.push({ environmentId: environment.environmentId, hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index fcd4bb4b8f2e..da19cc7d5a2b 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -171,3 +171,14 @@ export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connect staleTimeMs: 0, idleTtlMs: 0, }); + +/** Who a Simlock token is, checked as the host editor's URL and token fields change. */ +export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:device:simlock-identity-check", + tag: WS_METHODS.deviceSimlockIdentity, + staleTimeMs: 0, + idleTtlMs: 0, + }, +); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 2335d6ba8d65..24b72dbe26d7 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -127,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e4823f30272b..658f6276e247 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -293,6 +293,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -473,6 +476,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1613,6 +1617,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -2088,6 +2098,7 @@ export const WsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, From a5448f4c5030b4f70744db5ecfec4cc06828b621 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 18:05:40 -0700 Subject: [PATCH 25/38] fix(web): pull request diff follows the file tree, with a hide-tests filter (#26730) Co-authored-by: Claude Opus 5.5 --- .../pull-requests/PullRequestRouteScreen.tsx | 4 +- .../pull-requests/pullRequestFileFilters.ts | 10 +--- apps/web/src/components/DiffPanel.tsx | 48 +++++++++++++++++-- .../diffs/diffFileTree.logic.test.ts | 26 +++++++++- .../components/diffs/diffFileTree.logic.ts | 30 ++++++++++++ .../src/components/diffs/useHideTestFiles.ts | 9 ++++ .../pullRequest/PullRequestCodeTab.tsx | 48 +++++++++++++++++-- packages/shared/src/changedFileKind.ts | 5 ++ 8 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/diffs/useHideTestFiles.ts diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 80391ef4060e..446794800ead 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -41,7 +41,7 @@ import { pullRequestDiffInitialPosition, } from "./pullRequestDiffDebug"; import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; -import { isPullRequestTestFile } from "./pullRequestFileFilters"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; @@ -1035,7 +1035,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { const jumpedUnder = useRef(filter.hideTestFiles); const hasTestFiles = result.parsed.kind === "files" && - result.parsed.files.some((file) => isPullRequestTestFile(file.path)); + result.parsed.files.some((file) => isTestChangedFile(file.path)); const jumpStarted = () => jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles); const scrollToFile = (fileId: string) => { diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts index 2d8e3a94fe4b..2e0424ddbbae 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts @@ -1,12 +1,6 @@ -import { classifyChangedFile } from "@t3tools/shared/changedFileKind"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import type { ReviewParsedDiff } from "../review/reviewModel"; -/** Test support such as fixtures and snapshots is hidden along with the tests. */ -export function isPullRequestTestFile(path: string): boolean { - const kind = classifyChangedFile(path); - return kind === "tests" || kind === "mocks"; -} - export function filterPullRequestTestFiles( parsed: ReviewParsedDiff, hideTestFiles: boolean, @@ -14,7 +8,7 @@ export function filterPullRequestTestFiles( ): ReviewParsedDiff { if (!hideTestFiles || parsed.kind !== "files") return parsed; const files = parsed.files.filter( - (file) => file.path === includePath || !isPullRequestTestFile(file.path), + (file) => file.path === includePath || !isTestChangedFile(file.path), ); if (files.length === parsed.files.length) return parsed; return { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c257d0ea52d3..ddce50db3cfe 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,6 +12,7 @@ import { CheckIcon, ChevronDownIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, PilcrowIcon, Rows3Icon, @@ -37,6 +38,8 @@ import { useDiffPanelStore, } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; +import { useHideTestFiles } from "./diffs/useHideTestFiles"; import { useRenderedAppearance } from "../hooks/useTheme"; import { useStableCallback } from "../hooks/useStableCallback"; import { @@ -218,6 +221,7 @@ export default function DiffPanel({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); const [codeViewRevision, setCodeViewRevision] = useState(0); @@ -539,6 +543,16 @@ export default function DiffPanel({ const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = renderableFiles.map(getCachedFileEntry); const diffFileKeys = renderableFileEntries.map((file) => file.fileKey); + const testFileCount = renderableFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // Hiding is display-only: lazy loading indexes into the full list. A requested file stays visible. + const isFileShown = (file: FileDiffMetadata) => { + const path = resolveFileDiffPath(file); + return !hideTestFiles || path === selectedFilePath || !isTestChangedFile(path); + }; + const shownFileEntries = renderableFileEntries.filter(({ fileDiff }) => isFileShown(fileDiff)); + const shownFileKeys = shownFileEntries.map((file) => file.fileKey); const defaultCollapsedDiffFileKeys = settings.diffFilesCollapsed ? new Set(diffFileKeys) : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -561,7 +575,7 @@ export default function DiffPanel({ count={renderableFiles.length - settledFileCount} /> ) : null; - const codeViewFiles = renderableFileEntries + const codeViewFiles = shownFileEntries .filter( ({ fileDiff }) => !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), @@ -574,10 +588,12 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, })); - const pendingFiles = renderableFiles.filter((file) => file.cacheKey?.endsWith(":pending")); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const pendingFiles = renderableFiles.filter( + (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), + ); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); - const fileTreeEntries = diffFileTreeEntries(renderableFiles); + const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( @@ -661,7 +677,7 @@ export default function DiffPanel({ const toggleDiffFileCollapse = () => { setCodeViewRevision((current) => current + 1); - updateCollapsedDiffFiles((current) => toggleAllDiffFiles(diffFileKeys, current)); + updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; const selectTurn = (runId: RunId) => { @@ -991,6 +1007,28 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {testFileCount > 0 && ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} {diffFileKeys.length > 0 && ( { }); }); +describe("orderDiffFilesLikeTree", () => { + it("pulls a test listed last into its directory, keeping the first-seen order", () => { + expect( + orderDiffFilesLikeTree([ + file("change", "server/device/Host.ts"), + file("change", "web/settings.ts"), + file("change", "server/ws.ts"), + file("change", "server/device/Host.test.ts"), + file("change", "web/settings.test.ts"), + ]).map((entry) => entry.name), + ).toEqual([ + "server/device/Host.ts", + "server/device/Host.test.ts", + "server/ws.ts", + "web/settings.ts", + "web/settings.test.ts", + ]); + }); +}); + describe("buildDiffFileTreeUpdates", () => { it("adds a new file's directories before the file", () => { expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index abb3a00c7689..41d2a5e676e7 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -98,6 +98,36 @@ export function diffFileTreePositions(paths: ReadonlyArray): ReadonlyMap return positions; } +// A flat list in tree order: each directory stays contiguous, siblings ranked as +// compareDiffFileTreeEntries ranks them, so the list never disagrees with the tree. +export function orderDiffFilesLikeTree( + files: ReadonlyArray, +): ReadonlyArray { + const paths = files.map(resolveFileDiffPath); + const positions = diffFileTreePositions(paths); + const keys = paths.map((path) => { + const key: number[] = []; + let directory = ""; + for (const segment of path.split("/").slice(0, -1)) { + directory += `${segment}/`; + key.push(positions.get(directory)!); + } + key.push(positions.get(path)!); + return key; + }); + return files + .map((file, index) => ({ file, key: keys[index]! })) + .toSorted((left, right) => { + const length = Math.min(left.key.length, right.key.length); + for (let index = 0; index < length; index++) { + const difference = left.key[index]! - right.key[index]!; + if (difference !== 0) return difference; + } + return left.key.length - right.key.length; + }) + .map(({ file }) => file); +} + export function compareDiffFileTreeEntries( getPositions: () => ReadonlyMap, ): FileTreeSortComparator { diff --git a/apps/web/src/components/diffs/useHideTestFiles.ts b/apps/web/src/components/diffs/useHideTestFiles.ts new file mode 100644 index 000000000000..a1f806ecb467 --- /dev/null +++ b/apps/web/src/components/diffs/useHideTestFiles.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "~/hooks/useLocalStorage"; + +const HIDE_TEST_FILES_STORAGE_KEY = "t3code.diffHideTestFiles"; + +export function useHideTestFiles() { + return useLocalStorage(HIDE_TEST_FILES_STORAGE_KEY, false, Schema.Boolean); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d1b165c62147..94f4ccda8255 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, InfoIcon, MessageSquareOffIcon, @@ -51,6 +52,7 @@ import { canEditPullRequestComment, canResolvePullRequestThread, } from "@t3tools/client-runtime/pull-request-editing"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -80,7 +82,8 @@ import { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; -import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; +import { useHideTestFiles } from "../diffs/useHideTestFiles"; +import { diffFileTreeEntries, orderDiffFilesLikeTree } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -307,6 +310,7 @@ function PullRequestCodeTab({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -496,9 +500,22 @@ function PullRequestCodeTab({ ignoreWhitespace, ), ); - const files = parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + const loadedFiles = orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), ); + const testFileCount = loadedFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // A requested file stays visible so a jump to a hidden test still lands. + const files = hideTestFiles + ? loadedFiles.filter((file) => { + const path = resolveFileDiffPath(file); + return path === revealRequest?.path || !isTestChangedFile(path); + }) + : loadedFiles; + const hiddenTestFileCount = loadedFiles.length - files.length; const filePaths = files.map((file) => resolveFileDiffPath(file)); const omittedFileStats = new Map( loadedSlices.flatMap((slice) => @@ -1071,6 +1088,11 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {hiddenTestFileCount > 0 ? ( + + ) : null} {filesViewed.enabled && files.length > 0 ? ( @@ -1283,6 +1305,26 @@ function PullRequestCodeTab({ ) : null} + {testFileCount > 0 ? ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles ? "Show test files" : "Hide test files"} + + + ) : null} {fileKeys.length > 0 ? ( Date: Wed, 28 Oct 2026 18:09:45 -0700 Subject: [PATCH 26/38] fix(mobile): delivered messages no longer reappear at the bottom of long threads (#26739) Co-authored-by: Claude Opus 5.5 --- .../threads/pending-thread-feed.test.ts | 9 ++- .../features/threads/pending-thread-feed.ts | 18 +++-- .../src/state/acknowledged-thread-messages.ts | 32 +++++---- apps/mobile/src/state/thread-outbox-model.ts | 5 ++ .../src/state/thread-task-permissions.test.ts | 1 + .../threadOutboxDelivery.permissions.test.ts | 8 ++- .../src/state/threadOutboxDelivery.test.ts | 72 ++++++++++++------- apps/mobile/src/state/threadOutboxDelivery.ts | 23 +++++- .../src/state/use-thread-composer-state.ts | 7 ++ .../ThreadLaunchService.test.ts | 3 + .../orchestration-v2/ThreadLaunchService.ts | 4 ++ .../operations/commands.performance.test.ts | 2 +- .../client-runtime/src/state/threadDetail.ts | 7 ++ .../client-runtime/src/state/threadState.ts | 19 +++++ .../src/state/threads-sync.test.ts | 5 +- packages/client-runtime/src/state/threads.ts | 19 +++-- packages/contracts/src/orchestrationV2.ts | 2 + 17 files changed, 176 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 314dc1aa477b..3d9463c79bd0 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -114,14 +114,17 @@ describe("pending timeline messages", () => { expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]); }); - it("stops showing an acknowledged message once the projection holds it, even when the feed hides it", () => { + it("stops showing an acknowledged message once the thread catches up, even when the feed hides it", () => { // A rolled-back or cancelled queued run keeps its message in projection.messages // while its user_message item leaves the visible feed. const echoed = pending("rolled-back"); - const waiting = pending("waiting"); - const shown = selectPendingAcknowledgedMessages([echoed, waiting], { + // A bounded projection pages an applied message out; it must not come back as pending. + const pagedOut = { ...pending("paged-out"), sequence: 40 }; + const waiting = { ...pending("waiting"), sequence: 50 }; + const shown = selectPendingAcknowledgedMessages([echoed, pagedOut, waiting], { threadKey: "env:thread", queuedMessages: [], + sequence: 45, projectedMessages: [{ id: echoed.messageId }], }); expect(shown).toEqual([waiting]); diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index 9eaf42dcee81..d38cc026fdd6 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -5,7 +5,11 @@ import { } from "@t3tools/contracts"; import type { ThreadFeedEntry } from "../../lib/threadActivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { + AcknowledgedThreadMessage, + QueuedThreadMessage, +} from "../../state/thread-outbox-model"; /** * A guest's message waiting for the owner's Send or Dismiss. It is a feed row, not the list @@ -24,21 +28,25 @@ export type PendingThreadFeedEntry = ThreadFeedEntry & { readonly acknowledged?: boolean; }; -// An acknowledged row stops being pending once the projection holds its message, +// An acknowledged row stops being pending once the thread catches up to it, // even when the feed hides that message (a rolled-back or cancelled run). export function selectPendingAcknowledgedMessages( - acknowledged: ReadonlyArray, + acknowledged: ReadonlyArray, thread: { readonly threadKey: string | null; readonly queuedMessages: ReadonlyArray; + readonly sequence: number; readonly projectedMessages: ReadonlyArray<{ readonly id: string }> | undefined; }, -): ReadonlyArray { +): ReadonlyArray { return acknowledged.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === thread.threadKey && !thread.queuedMessages.some((queued) => queued.messageId === message.messageId) && - !thread.projectedMessages?.some((projected) => projected.id === message.messageId), + !threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: thread.projectedMessages, + }), ); } diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index ab9f26dd7d41..d7935d1debca 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,31 +1,39 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { AcknowledgedThreadMessage, QueuedThreadMessage } from "./thread-outbox-model"; import { environmentThreadDetails } from "./threads"; -// Keep acknowledged rows until their subscribed projection arrives; command receipts can precede messages. -// Render hides rows the projection has echoed, so dropping them here only frees memory. -export const acknowledgedThreadMessagesAtom = Atom.make>( +// Keep acknowledged rows until the thread catches up; command receipts can precede messages. +export const acknowledgedThreadMessagesAtom = Atom.make>( [], ).pipe(Atom.keepAlive); // Peeks at a thread only when something already has it in the registry: reading // it through the registry would open a snapshot load and a live stream of its own. -function isEchoedInMountedThread(message: QueuedThreadMessage): boolean { - const threadAtom = environmentThreadDetails.threadAtom({ +function isEchoedInMountedThread(message: AcknowledgedThreadMessage): boolean { + const stateAtom = environmentThreadDetails.stateAtom({ environmentId: message.environmentId, threadId: message.threadId, }); - const node = appAtomRegistry.getNodes().get(threadAtom); + const node = appAtomRegistry.getNodes().get(stateAtom); if (node === undefined) return false; - const loaded: Option.Option> = node.valueOption(); - const thread = Option.getOrNull(loaded); - return thread?.projection.messages.some((entry) => entry.id === message.messageId) ?? false; + const loaded: Option.Option> = node.valueOption(); + return Option.match(loaded, { + onNone: () => false, + onSome: (thread) => + threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: Option.getOrUndefined(thread.data)?.messages, + }), + }); } function writeAcknowledgedThreadMessages( - update: (current: ReadonlyArray) => ReadonlyArray, + update: ( + current: ReadonlyArray, + ) => ReadonlyArray, ) { const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); const next = update(current).filter((entry) => !isEchoedInMountedThread(entry)); @@ -35,7 +43,7 @@ function writeAcknowledgedThreadMessages( appAtomRegistry.set(acknowledgedThreadMessagesAtom, next); } -export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { +export function retainAcknowledgedThreadMessage(message: AcknowledgedThreadMessage) { writeAcknowledgedThreadMessages((current) => current.some((entry) => entry.messageId === message.messageId) ? current diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 16f2de8f099b..6c5da0db2099 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -287,3 +287,8 @@ export function resolveThreadOutboxFailureAction(input: { } return "restore"; } + +export type AcknowledgedThreadMessage = QueuedThreadMessage & { + /** The server sequence that committed the message; older servers do not report it. */ + readonly sequence?: number; +}; diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index acfdd803d425..0b529e994780 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -113,6 +113,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), + sequenceAtom: () => Atom.make(0), pendingRequestsAtom: () => Atom.make(state.pendingRequests), }, threadEnvironment: { diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index edc80c500702..0ea64764e5da 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -100,7 +100,7 @@ vi.mock("./threads", async () => { familyAtom: () => Atom.make(AsyncResult.success({})), refresh: vi.fn(), }, - environmentThreadDetails: { threadAtom: () => Atom.make(null) }, + environmentThreadDetails: { stateAtom: () => Atom.make(null) }, }; }); vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } })); @@ -224,6 +224,7 @@ beforeEach(() => { ]) { command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); } + state.start.mockResolvedValue(AsyncResult.success({})); state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ status: "ready", attachments: [], @@ -427,7 +428,8 @@ describe("queued task operation access", () => { ); await state.manager.enqueue(queued); const started = Promise.withResolvers(); - const delivery = Promise.withResolvers>(); + const delivery = + Promise.withResolvers>(); state.start.mockImplementationOnce(() => { started.resolve(); return delivery.promise; @@ -437,7 +439,7 @@ describe("queued task operation access", () => { setOperateGrant("secondary", false); delivery.resolve( accepted - ? AsyncResult.success(undefined) + ? AsyncResult.success({}) : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), ); await drained; diff --git a/apps/mobile/src/state/threadOutboxDelivery.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts index 901ffaa9e829..d77ffe1942b6 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -13,9 +13,12 @@ import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; const harness = vi.hoisted(() => ({ canOperate: true, - threadProjection: null as unknown as import("effect/reactivity").Atom.Writable<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>, + threadState: null as unknown as import("effect/reactivity").Atom.Writable<{ + readonly data: import("effect/Option").Option<{ + readonly messages: ReadonlyArray<{ readonly id: string }>; + }>; + readonly sequence: number; + }>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -92,12 +95,14 @@ vi.mock("./git", () => ({ gitEnvironment: {} })); vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); - harness.threadProjection = Atom.make<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>(null).pipe(Atom.keepAlive); + const Option = await import("effect/Option"); + harness.threadState = Atom.make({ + data: Option.none<{ readonly messages: ReadonlyArray<{ readonly id: string }> }>(), + sequence: 0, + }).pipe(Atom.keepAlive); return { threadEnvironment: {}, - environmentThreadDetails: { threadAtom: () => harness.threadProjection }, + environmentThreadDetails: { stateAtom: () => harness.threadState }, }; }); @@ -138,6 +143,7 @@ vi.mock("./thread-outbox", async () => { }; }); +import * as Option from "effect/Option"; import { appAtomRegistry } from "./atom-registry"; import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; import { clearThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; @@ -204,7 +210,7 @@ function remainingMessages(): ReadonlyArray { beforeEach(() => { appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); - appAtomRegistry.set(harness.threadProjection, null); + appAtomRegistry.set(harness.threadState, { data: Option.none(), sequence: 0 }); harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -330,26 +336,32 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { - it("drops echoed acknowledged rows at the next delivery without subscribing to the thread", async () => { + it("drops caught-up acknowledged rows at the next delivery without subscribing to the thread", async () => { const first = queuedMessage({ messageId: "awaiting-echo", text: "Delivered" }); const second = queuedMessage({ messageId: "next-delivery", text: "Also delivered" }); await harness.manager.enqueue(first); await harness.manager.enqueue(second); await expect( - completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId)), + completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId), 7), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); - - appAtomRegistry.set(harness.threadProjection, { - projection: { messages: [{ id: "unrelated" }, { id: first.messageId }] }, + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...first, sequence: 7 }, + ]); + + // The thread applied the message, but its bounded window does not hold it. + appAtomRegistry.set(harness.threadState, { + data: Option.some({ messages: [{ id: "unrelated" }] }), + sequence: 7, }); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toHaveLength(1); await expect( - completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId)), + completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId), 9), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([second]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...second, sequence: 9 }, + ]); }); it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { @@ -359,7 +371,11 @@ describe("thread outbox drain delivery cleanup", () => { harness.draftFile.setWriteError(new Error("Draft storage unavailable")); await expect( - completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + completeQueuedMessageDelivery( + message, + harness.manager.revisionOf(message.messageId), + undefined, + ), ).resolves.toBe("removed"); expect(remainingMessages()).toEqual([]); @@ -390,9 +406,9 @@ describe("thread outbox drain delivery cleanup", () => { ).toEqual([message]); if (cleared) await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( - cleared ? "edited" : "removed", - ); + await expect( + completeQueuedMessageDelivery(message, deliveryRevision, undefined), + ).resolves.toBe(cleared ? "edited" : "removed"); appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, @@ -412,7 +428,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.update(edited); await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); await composerDrafts.restoreCloudComposerDrafts("account-a"); expect(remainingMessages()).toEqual([edited]); }); @@ -447,7 +465,9 @@ describe("thread outbox drain delivery cleanup", () => { const edited = { ...message, text: "edited while the turn delivered" }; await harness.manager.update(edited); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -458,7 +478,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "removed", + ); expect(remainingMessages()).toEqual([]); expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); @@ -479,7 +501,7 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - const cleanup = completeQueuedMessageDelivery(message, deliveryRevision); + const cleanup = completeQueuedMessageDelivery(message, deliveryRevision, undefined); await removeStarted.promise; appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); removeBarrier.resolve(); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index 5ba633de634a..4a20db3b9e96 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -250,6 +250,7 @@ function isQueuedMessagePayloadCurrent( export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, + sequence: number | undefined, ): Promise<"removed" | "edited" | "failed"> { clearThreadComposerError( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), @@ -266,7 +267,9 @@ export async function completeQueuedMessageDelivery( if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "edited"; } - retainAcknowledgedThreadMessage(queuedMessage); + retainAcknowledgedThreadMessage( + sequence === undefined ? queuedMessage : { ...queuedMessage, sequence }, + ); const removed = await removeThreadOutboxMessage( queuedMessage, deliveryRevision, @@ -616,6 +619,12 @@ const settledCreationOutcomeKeysAtom = Atom.make((get): ReadonlyArray => ); }).pipe(Atom.withLabel("mobile:thread-outbox:settled-creation-outcomes")); +function deliveredSequence( + result: AtomCommandResult<{ readonly sequence?: number }, unknown>, +): number | undefined { + return AsyncResult.isSuccess(result) ? result.value.sequence : undefined; +} + const runDeliveryCommand = ( command: AtomCommand, input: W, @@ -896,7 +905,11 @@ export function createThreadOutboxDelivery(): { } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + (await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + )) === "removed"; if (delivered) { acknowledgedExistingThreadMessageIds.delete(persistedMessage.messageId); } @@ -1052,7 +1065,11 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); - const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + const outcome = await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + ); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return true; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 45f06e198f47..4c14fb56254c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -110,6 +110,7 @@ const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); +const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -227,6 +228,11 @@ export function useThreadComposerState() { const selectedThreadMessages = selectedThreadProjection?.projection.messages; const selectedThreadAttempts = selectedThreadProjection?.projection.attempts; const selectedThreadNodes = selectedThreadProjection?.projection.nodes; + const selectedThreadSequence = useAtomValue( + selectedThreadDetailRef === null + ? EMPTY_THREAD_SEQUENCE_ATOM + : environmentThreadDetails.sequenceAtom(selectedThreadDetailRef), + ); const checkpointSummaries = useAtomValue( selectedThreadDetailRef === null ? EMPTY_CHECKPOINT_SUMMARIES_ATOM @@ -254,6 +260,7 @@ export function useThreadComposerState() { const pendingAcknowledgments = selectPendingAcknowledgedMessages(acknowledgedMessages, { threadKey: selectedThreadKey, queuedMessages: selectedThreadQueuedMessages, + sequence: selectedThreadSequence, projectedMessages: selectedThreadMessages, }); if (pendingAcknowledgments.length === 0) return feed; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 88fc87d3bc09..ea1669f4b7dd 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1380,6 +1380,9 @@ it.effect("replays a server-allocated launch", () => assert.equal(first.threadId, retry.threadId); assert.isFalse(first.resumed); assert.isTrue(retry.resumed); + // Clients drop their pending copy once the thread reaches the message's commit. + assert.isNumber(first.sequence); + assert.equal(retry.sequence, first.sequence); assert.equal(harness.runSetup.mock.calls.length, 1); assert.equal(retry.projection.messages.length, 1); assert.equal(retry.projection.runs.length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 289df535c12e..7a45a54a312f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -123,6 +123,7 @@ export interface ThreadLaunchResult { readonly projection: OrchestrationV2ThreadProjection; /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */ readonly resumed: boolean; + readonly sequence?: number; } export class ThreadLaunchError extends Schema.TaggedError()( @@ -941,6 +942,7 @@ const make = Effect.gen(function* () { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } let runId: RunId | null = null; + let messageSequence: number | undefined; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); @@ -987,6 +989,7 @@ const make = Effect.gen(function* () { ); // A concurrent retry can read no receipt and still replay the dispatch. messageWasAlreadyAccepted ||= dispatched.replayed === true; + messageSequence = dispatched.sequence; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -1053,6 +1056,7 @@ const make = Effect.gen(function* () { input.initialMessage === undefined ? Option.isSome(launchReceipt) || claimed.replayed === true : messageWasAlreadyAccepted, + ...(messageSequence === undefined ? {} : { sequence: messageSequence }), }; }); }, diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 2378407c2df7..d4be3f1a9f45 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -223,7 +223,7 @@ describe("routine command transport budget", () => { if (command.type !== "message.dispatch") { throw new Error(`Expected message.dispatch, received ${command.type}`); } - if (!("sequence" in receipt)) { + if ("projection" in receipt) { throw new Error("Expected a dispatch receipt"); } const { deliveryIntent: _deliveryIntent, ...formerCommand } = command; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0c9380037879..e1f30998caef 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -409,6 +409,12 @@ export function createEnvironmentThreadDetailAtoms( Atom.withLabel(`environment-thread-status:${key}`), ), ); + const sequenceAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).sequence).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-sequence:${key}`), + ), + ); const errorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( Atom.setIdleTTL(0), @@ -439,6 +445,7 @@ export function createEnvironmentThreadDetailAtoms( quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), + sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)), }; diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab1..3b53b68b2f4f 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -14,6 +14,8 @@ export interface EnvironmentThreadState { * disconnect path. */ readonly history: ThreadHistoryMeta; + /** The last server sequence `data` reflects, so readers can tell a write has landed without searching paged-out history. */ + readonly sequence: number; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { @@ -21,4 +23,21 @@ export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: 0, }; + +/** + * A thread holds a write once it has applied the write's commit sequence. A bounded + * projection can page the message out later, so finding it is only a fallback for + * servers that do not report the sequence. + */ +export function threadHasCaughtUpTo( + write: { readonly messageId: string; readonly sequence?: number | undefined }, + thread: { + readonly sequence: number; + readonly messages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): boolean { + if (write.sequence !== undefined && thread.sequence >= write.sequence) return true; + return thread.messages?.some((entry) => entry.id === write.messageId) ?? false; +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c8c212db41ca..eaf1751a1c2d 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -678,7 +678,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); - yield* awaitThreadState( + const live = yield* awaitThreadState( harness.observed, (value) => value.status === "live" && @@ -686,6 +686,8 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); + // Readers compare this to a command's commit sequence; it moves with the data it describes. + expect(live.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -1288,6 +1290,7 @@ describe("EnvironmentThreads", () => { status: "cached", error: Option.none(), history: { ...EMPTY_THREAD_HISTORY_META, historyCursor: null, expanded: true }, + sequence: CACHED_SNAPSHOT_SEQUENCE, }, sequence: CACHED_SNAPSHOT_SEQUENCE, persisted: true, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 3cb4192f0ab6..c7b22d9dcdff 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -224,6 +224,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { @@ -234,15 +239,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => EMPTY_THREAD_HISTORY_META, onSome: historyMetaFromCachedSnapshot, }), + sequence: initialSequence, }; const state = yield* SubscriptionRef.make(initialState); const canLoadHistory = Option.isSome(httpClient) && Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -400,6 +401,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -420,6 +422,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ("live" as const), error: previous.error, history, + sequence, }; }); // Active projections can update many times per second and retain large tool @@ -451,6 +454,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: yield* SubscriptionRef.get(lastSequence), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; @@ -571,7 +575,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + // A batch that changed nothing keeps its state object, so readers do not re-render. + return [result, result._tag === "noop" ? current : { ...current, sequence }]; }, ); @@ -796,7 +801,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); if (result._tag !== "loaded") return result; - if (complete) yield* remember; + if (complete) yield* applyLock.withPermits(1)(remember); nextCursor = keepPaging ? page.nextCursor : null; } if (nextCursor === null) return { _tag: "loaded" } as const; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a1dd871205bb..498f81a0e848 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3573,6 +3573,8 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, + /** The initial message's commit sequence. Older servers omit it. */ + sequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; From d8f4980f6c0ba0b1ea3c52b69f3f3794f6973d3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 18:17:44 -0700 Subject: [PATCH 27/38] ci(mobile): fork PRs now get the Native Change label (#26755) Co-authored-by: Claude Opus 5.5 --- .../workflows/mobile-fingerprint-check.yml | 106 ++-------- .../workflows/mobile-fingerprint-label.yml | 186 ++++++++++++++++++ 2 files changed, 201 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/mobile-fingerprint-label.yml diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6..9fabb3a5f2ad 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check # Detects whether a PR changes the native fingerprint — i.e. whether merging # it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "📱 Native Change" label so they can be held and merged as a +# PRs get the "📱 Native Change" label (applied by mobile-fingerprint-label.yml, +# which runs from main so fork PRs get it too) so they can be held and merged as a # batch right before the next store submission, keeping main OTA-able for # everything else in between. (Once one native PR merges, every later merge # inherits the drifted fingerprint and loses OTA reach too — that is why the @@ -27,8 +28,6 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read - issues: write - pull-requests: write env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -127,94 +126,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target — it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 + # mobile-fingerprint-label.yml reads this from main with a write token + # and syncs the label; this job's token is read-only for fork PRs. + - name: Record changed platforms env: CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "📱 Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = context.payload.pull_request.number; - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } + run: | + mkdir -p "$RUNNER_TEMP/fingerprint-result" + printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); + - name: Upload changed platforms + uses: actions/upload-artifact@v7 + with: + name: fingerprint-result + path: ${{ runner.temp }}/fingerprint-result + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml new file mode 100644 index 000000000000..191c95327997 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -0,0 +1,186 @@ +name: Mobile Fingerprint Label + +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code +# (fork PRs included) with a read-only token and records which platforms' +# fingerprints changed. This one runs from main with a write token and syncs +# the "📱 Native Change" label. It never checks out or runs PR code. +# +# A PR can edit the check workflow it runs under, so the recorded result is +# untrusted data: it is validated, and at worst a PR mislabels itself. + +on: + workflow_run: + workflows: [Mobile Fingerprint Check] + types: [completed] + +permissions: + contents: read + +jobs: + label: + name: Sync native change label + # A cancelled run was superseded by a newer push, whose run labels instead. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + issues: write + pull-requests: write + steps: + - id: pr + name: Resolve the pull request behind the check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const results = artifacts.filter( + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, + ); + if (results.length !== 1) { + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); + return; + } + + // workflow_run.pull_requests is empty for fork PRs, so resolve the + // PR from the checked commit. Requiring the PR's current head to be + // that commit keeps a slow run for an older push from overwriting a + // newer result. + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, + ); + const matching = associated.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); + return; + } + + core.setOutput("artifact_id", String(results[0].id)); + core.setOutput("pr_number", String(matching[0].number)); + + # Read the archive as bytes; never extract PR-produced files into a + # workspace this job runs code from. + - id: result + name: Read changed platforms + if: steps.pr.outputs.pr_number != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then + echo "Unexpected changed-platforms value '$changed'." >&2 + exit 1 + fi + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + if: steps.pr.outputs.pr_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = Number(process.env.PR_NUMBER); + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); From 5eebf77b373b719ca4456985340707afa267310e Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 01:27:58 +0000 Subject: [PATCH 28/38] fix(claude): command approvals show the command, not Claude's description of it (#20145) --- .../Adapters/ClaudeAdapterV2.test.ts | 20 +++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ba4b7444b7b0..47457be66099 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -893,8 +893,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -903,6 +906,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", + description: "Check the Node runtime", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -915,12 +919,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + requestId: event!.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 030fb5785e6d..dd4fc8f30af9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9551,7 +9551,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? callbackOptions.title ?? callbackOptions.description ?? callbackOptions.decisionReason ?? From 7b87466731bfe3662fd86689e8754c494567bca8 Mon Sep 17 00:00:00 2001 From: jan Date: Thu, 29 Oct 2026 02:56:40 +0100 Subject: [PATCH 29/38] docs: add nixpkgs installation instructions (#26750) --- README.md | 10 ++++++++++ docs/user/install.md | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 36f558a79dc6..6f6dab985e15 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +#### Nix (nixpkgs) + +```bash +nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code +``` + +The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) +and includes the desktop app (`t3code-desktop`) and the `t3` command. +See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/docs/user/install.md b/docs/user/install.md index 3edf46d80ee3..1e1693d0b32e 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -65,18 +65,41 @@ update it with `git pull` and a rebuild. Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or use a package manager: -| Platform | Install | -| ------------------ | ---------------------------------- | -| Windows | `winget install T3Tools.T3Code` | -| macOS | `brew install --cask t3-code` | -| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | -| Arch Linux | `yay -S t3code-bin` | -| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Platform | Install | +| ------------------ | ------------------------------------------------------------ | +| Windows | `winget install T3Tools.T3Code` | +| macOS | `brew install --cask t3-code` | +| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Arch Linux | `yay -S t3code-bin` | +| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### Nix (nixpkgs) + +T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). +The command above installs from `nixos-unstable` and requires Nix 2.30 or later with +`nix-command` and `flakes` enabled. Launch the desktop app with `t3code-desktop`; +the package also includes the `t3` command. + +On NixOS, you can instead add the package to your system configuration, using +a nixpkgs revision that includes `t3code`: + +```nix +environment.systemPackages = [ pkgs.t3code ]; +``` + +Apply the change with `sudo nixos-rebuild switch`. For Home Manager, add +`pkgs.t3code` to `home.packages` and run `home-manager switch`. + +Update a profile installation with `nix profile upgrade t3code`. For NixOS or +Home Manager, update your configuration's nixpkgs input or channel and rebuild. +Nix manages both the desktop app and the bundled +`t3` command; use Nix to update them instead of the in-app updater or `t3 update`. + ### The `t3` command The desktop app includes the `t3` command-line tool. To run it from any From 861a51a6c4a036a6f14fea2a6f093808de1d96ce Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 02:07:28 +0000 Subject: [PATCH 30/38] feat(server,web,mobile): fall back when the default provider is out of usage (#26164) Co-authored-by: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 17 ++- .../src/mcp/toolkits/project/handlers.test.ts | 58 ++++++++++ .../src/mcp/toolkits/project/handlers.ts | 16 ++- apps/server/src/mcp/toolkits/project/tools.ts | 2 + apps/server/src/serverSettings.ts | 1 + .../CodexTextGeneration.test.ts | 103 +++++++++++++++++ .../src/textGeneration/TextGeneration.ts | 105 +++++++++++++++--- apps/web/src/components/ChatView.tsx | 98 ++++++++++++++-- .../components/chat/ComposerUsageLimits.tsx | 35 ++++++ .../src/components/chat/DraftHeroHeadline.tsx | 35 +++++- .../settings/ProjectDefaultsSettings.tsx | 63 +++++++++++ apps/web/src/hooks/useHandleNewThread.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.ts | 23 +++- apps/web/src/providerInstances.ts | 16 +++ packages/contracts/src/settings.ts | 4 + packages/shared/src/serverSettings.test.ts | 18 +++ packages/shared/src/serverSettings.ts | 19 ++-- packages/shared/src/usageLimits.test.ts | 103 +++++++++++++++++ packages/shared/src/usageLimits.ts | 83 ++++++++++++++ 19 files changed, 763 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index b2159706abee..14a9720cb4e9 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useNowMinuteMs } from "../../lib/clock"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -483,6 +485,7 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); + const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, @@ -501,12 +504,24 @@ export function NewTaskFlowProvider( selectedProject?.id ?? null, ); - const selectedModel = resolveNewTaskModelSelection({ + const selectedDefaultModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); + const selectedModel = resolveNewThreadModelSelection({ + defaultSelection: selectedDefaultModel, + explicitSelection: + draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, + providers: selectedEnvironmentServerConfig?.providers ?? [], + ...(selectedEnvironmentServerConfig + ? { settings: selectedEnvironmentServerConfig.settings } + : {}), + projectId: selectedProject?.id ?? null, + now: modelSelectionNow, + }).selection; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 0d7ca3e1c9d9..03b97c32af6d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,8 +1,11 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, + ProviderDriverKind, + type ServerProvider, type Project as ProjectRecord, type ModelSelection, type RuntimeMode, @@ -37,6 +40,8 @@ import { ProjectToolkit } from "./tools.ts"; const clientLaunchHarness = (input: { readonly runtimeModeCeiling: RuntimeMode; readonly defaultRuntimeMode?: RuntimeMode; + readonly fallbackModel?: ModelSelection; + readonly providers?: readonly ServerProvider[]; /** Where Settings saves a project's model; the legacy project field stays null. */ readonly overrideModel?: ModelSelection; readonly launched: Array; @@ -54,7 +59,11 @@ const clientLaunchHarness = (input: { model: input.overrideModel === undefined ? modelSelection : null, }; const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(input.providers ?? []), + }), ServerSettings.layerTest({ + fallbackModelSelection: input.fallbackModel ?? null, ...(input.defaultRuntimeMode === undefined ? {} : { defaultRuntimeMode: input.defaultRuntimeMode }), @@ -473,3 +482,52 @@ it.effect("a retried worktree launch replays after its base ref is gone", () => ]); }), ); + +it.effect("a client launches with the fallback for an exhausted default", () => + Effect.gen(function* () { + const launched: ThreadLaunch.ThreadLaunchInput[] = []; + const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(driver === "claudeAgent" + ? { + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + fallbackModel, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fallback", + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: fallbackModel, + }); + expect(launched[0]?.modelSelection).toEqual(fallbackModel); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 79f18acbc2da..014b109448f3 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -18,6 +18,9 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as DateTime from "effect/DateTime"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -180,7 +183,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { input.modelSelection ?? launchedModel ?? caller?.modelSelection ?? - projectDefaults?.defaultModelSelection ?? + (projectDefaults + ? resolveNewThreadModelSelection({ + defaultSelection: projectDefaults.defaultModelSelection, + fallbackSelection: projectDefaults.fallbackModelSelection, + providers: yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ), + settings: projectDefaults, + projectId, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection + : null) ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b..3ea5a8ea9e48 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -23,6 +23,7 @@ import { import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e..a1e0d34c7dd5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -435,6 +435,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", + "fallbackModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index a83e09a4110d..04ae83952b3c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,3 +1,13 @@ +import { + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProviderDriverKind, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -433,3 +443,96 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); }); + +it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhausted) => + Effect.gen(function* () { + const primary = ProviderInstanceId.make("codex"); + const fallback = ProviderInstanceId.make("claude_work"); + const selection = { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }; + const calls: ModelSelection[] = []; + const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { + const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); + const snapshot: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(exhausted && instanceId === primary + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }; + return { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { title: "Generated title" }; + }), + }), + }; + }); + const service = yield* TextGeneration.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + }), + ServerSettings.layerTest({ + providerInstances: { + [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused"), + }), + ), + ), + ); + expect( + yield* service.generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: selection, + }), + ).toEqual({ title: "Generated title" }); + expect(calls).toEqual([ + exhausted + ? { + instanceId: fallback, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + } + : selection, + ]); + }), +); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 96f266c6f895..fc8d0fb68da3 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { + isProviderAvailable, + isProviderTextGenerationCapable, + resolveProjectProviderInstanceEnabled, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; +import { + defaultTextGenerationModel, + fallbackTextGenerationModelSelection, +} from "@t3tools/shared/serverSettings"; +import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -52,36 +64,94 @@ const resolveInstance = ( ), ); +// Review guides investigate the checkout read-only; the other drivers reject that request. +const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]); + /** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const generate = Effect.fn("TextGeneration.generate")(function* ( + operation: TextGenerationOp, + selection: ModelSelection, + invoke: ( + service: ProviderTextGeneration, + modelSelection: ModelSelection, + ) => Effect.Effect, + ) { + const snapshots = yield* registry.listInstances.pipe( + Effect.flatMap((instances) => + Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot), + ), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const limited = exhaustedUsageWindow( + snapshots.find((provider) => provider.instanceId === selection.instanceId), + now, + selection.model, + ); + if (limited) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Could not read fallback model settings.", + cause, + }), + ), + ); + selection = + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) + ); + }, + { includeConfiguredInstances: true }, + ) ?? selection; + } + return yield* resolveInstance(registry, operation, selection.instanceId).pipe( + Effect.flatMap((service) => invoke(service, selection)), + ); + }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return resolveInstance( - registry, - "generateCommitMessage", - input.modelSelection.instanceId, - ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => + service.generateCommitMessage({ ...input, modelSelection }), + ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + return generate("generatePrContent", input.modelSelection, (service, modelSelection) => + service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + return generate("generateBranchName", input.modelSelection, (service, modelSelection) => + service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), + return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => + service.generateReviewGuide({ ...input, modelSelection }), ); }), generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { - return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => + return generate( + "generateThreadTitle", + input.modelSelection, + (textGeneration, modelSelection) => Effect.gen(function* () { const linkedContext = input.linkedContext ?? @@ -91,9 +161,12 @@ export const make = Effect.gen(function* () { sourceControl, ), )); - return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); + return yield* textGeneration.generateThreadTitle({ + ...input, + linkedContext, + modelSelection, + }); }), - ), ); }), }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253cc1ea2e84..d6d3e0953607 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; @@ -189,6 +190,7 @@ import { newMessageId } from "~/lib/utils"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, + resolveProjectDefaultProviderModelSelection, shouldShowInstanceBadge, } from "../providerInstances"; import { @@ -305,6 +307,7 @@ import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -400,6 +403,8 @@ type ChatViewProps = draftId: DraftId; }; +const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -586,6 +591,9 @@ export default function ChatView(props: ChatViewProps) { ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) : null; }); + const composerModelSelectionExplicit = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, + ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -751,15 +759,37 @@ export default function ChatView(props: ChatViewProps) { }); } } + const nowMinute = useNowMinute(); + const draftDefaultModelSelection = draftThread + ? resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection + : null; + const draftModelResolution = draftThread + ? resolveNewThreadModelSelection({ + defaultSelection: + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + draftThread.projectId, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }) + : null; + const draftFallback = draftModelResolution?.fallback ? draftModelResolution : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + (draftFallback ? draftFallback.selection : draftDefaultModelSelection) ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1052,7 +1082,10 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); - const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultModelSelection = + !isServerThread && draftFallback + ? draftFallback.selection + : activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; @@ -1624,6 +1657,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed, ]); + const [dismissedFallbackWindow, setDismissedFallbackWindow] = useLocalStorage( + `t3code:fallback-notice-dismissed:${environmentId}`, + null, + DismissedFallbackWindowSchema, + ); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -2308,7 +2346,6 @@ export default function ChatView(props: ChatViewProps) { closeRightPanelSurface, }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const nowMinute = useNowMinute(); const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2478,12 +2515,58 @@ export default function ChatView(props: ChatViewProps) { }) : null; const composerBannerItems = (() => { + const fallbackSelection = draftFallback?.selection; + const defaultSelection = + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providerStatuses, + settings, + draftThread?.projectId ?? null, + ); + const fallback = + !isServerThread && + multipleModelSelections === null && + !composerModelSelectionExplicit && + draftFallback?.fallback && + (!composerSelectedModelSelection || + (composerSelectedModelSelection.instanceId === fallbackSelection?.instanceId && + composerSelectedModelSelection.model === fallbackSelection?.model)) + ? draftFallback.fallback + : null; + const fallbackWindow = fallback + ? `${fallback.provider.instanceId}:${fallback.resetsAt ?? ""}` + : null; + const fallbackItems: ComposerBannerStackItem[] = + fallback && + fallbackSelection && + defaultSelection && + fallbackWindow !== dismissedFallbackWindow + ? [ + modelFallbackBannerItem({ + fallbackModelName: + providerStatuses + .find((provider) => provider.instanceId === fallbackSelection.instanceId) + ?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? + fallbackSelection.model, + defaultProviderName: fallback.provider.displayName ?? fallback.provider.driver, + defaultModelName: + fallback.provider.models.find((model) => model.slug === defaultSelection.model) + ?.name ?? defaultSelection.model, + resetsAt: fallback.resetsAt, + timestampFormat, + onUseDefault: () => + onProviderModelSelectStable(defaultSelection.instanceId, defaultSelection.model), + onDismiss: () => setDismissedFallbackWindow(fallbackWindow), + }), + ] + : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2492,6 +2575,7 @@ export default function ChatView(props: ChatViewProps) { } return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 294816c140a2..12c11d213cdf 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,8 +1,10 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; @@ -67,6 +69,39 @@ export function usageLimitsBannerItem( }; } +export function modelFallbackBannerItem({ + fallbackModelName, + defaultProviderName, + defaultModelName, + resetsAt, + timestampFormat, + onUseDefault, + onDismiss, +}: { + readonly fallbackModelName: string; + readonly defaultProviderName: string; + readonly defaultModelName: string; + readonly resetsAt: string | null; + readonly timestampFormat: TimestampFormat; + readonly onUseDefault: () => void; + readonly onDismiss: () => void; +}): ComposerBannerStackItem { + return { + id: "fallback-model", + variant: "info", + icon: , + title: `Using ${fallbackModelName}`, + description: `${defaultProviderName} is out of usage${resetsAt ? ` · resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, + actions: ( + + ), + dismissLabel: "Dismiss fallback model notice", + onDismiss, + }; +} + function UsageLimitsBannerBody({ report, environmentId, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4a8a894bedbb..d2e259692204 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -25,7 +25,11 @@ import { projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironments, + usePrimaryEnvironmentId, + useEnvironmentProviders, +} from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; @@ -45,6 +49,8 @@ import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { TriageDraftHero } from "./TriageDraftHero"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -67,6 +73,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); + const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); @@ -193,13 +200,33 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( + const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings + )?.serverConfig; + const environmentSettings = environmentConfig?.settings; + const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings + ? null + : resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + environmentSettings, + project.id, + )), + fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, + providers: providersByEnvironment.get(project.environmentId) ?? [], + ...(environmentSettings ? { settings: environmentSettings } : {}), + projectId: project.id, + now: Date.now(), + }); + const defaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : defaultSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 0bfa19498f6d..43c4419962a0 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -77,6 +77,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); + const fallbackSelection = settings.fallbackModelSelection ?? selection; + const fallbackModelOptions = getCustomModelOptionsByInstance( + settings, + providers, + fallbackSelection?.instanceId, + fallbackSelection?.model, + ); const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); @@ -216,6 +224,59 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> ); + const fallbackRow = !isProjectScope ? ( + updateSettings({ fallbackModelSelection: null })} + /> + ) : null + } + control={ + fallbackSelection ? ( + { + const reason = modelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Fallback model not saved", + description: reason, + }); + return; + } + updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + }} + /> + ) : ( + No providers available + ) + } + /> + ) : null; + return ( {modelRow} + {fallbackRow} {workspaceRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 235dc557bab6..e95a2d0d799b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -144,7 +146,26 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; + const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + configuredDefaultModelSelection ?? + (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null + ? null + : resolveProjectDefaultProviderModelSelection( + environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + targetServerSettings, + projectRef.projectId, + )), + fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + settings: targetServerSettings, + projectId: projectRef.projectId, + now: Date.now(), + }); + const projectDefaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : configuredDefaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index a3364df9a15d..f9e711c40e7f 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -240,3 +240,19 @@ export function resolveDefaultProviderModelSelection( const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } + +export function resolveProjectDefaultProviderModelSelection( + providers: ReadonlyArray, + settings: ServerSettings, + projectId: ProjectId | null, +): ModelSelection | null { + const entries = providerInstanceEntriesFor(providers, settings, projectId); + // Matches resolveComposerProviderSelection: the first driver group wins before any other. + const entry = + resolveSelectableProviderInstanceEntry( + entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), + undefined, + ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); + const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + return entry && model ? { instanceId: entry.instanceId, model } : null; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dda56d86bae6..438d8fa6ee74 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1179,6 +1179,9 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + fallbackModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), @@ -1537,6 +1540,7 @@ export const ServerSettingsPatch = Schema.Struct({ ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), + fallbackModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), /** Replaces each project's override set; null removes it. Omitted providerInstances retains its stored value. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 97846939d31c..f9a43b6123a1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -24,6 +24,24 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, + }, + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); + expect( + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 0c151eb5025a..e7c856070658 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -92,13 +92,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( @@ -538,6 +540,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d7..5da7bc2cc02d 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -17,9 +18,11 @@ import { collectLimitNotices, collectLimitPools, elapsedShare, + exhaustedUsageWindow, paceOf, paceRemainingPercent, usesChatGptSharing, + resolveNewThreadModelSelection, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -50,6 +53,106 @@ function provider(overrides: Partial): ServerProvider { }; } +describe("new thread model fallback", () => { + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; + const limited = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + }, + }); + const available = provider({ + instanceId: fallback.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }); + + const defaults = { + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }; + it.each([ + ["exhausted default", {}, fallback], + ["explicit selection", { explicitSelection: selection }, selection], + ["missing default", { defaultSelection: null }, null], + ["no fallback", { fallbackSelection: null }, selection], + ["expired window", { now: Date.parse("2026-09-04T00:00:00Z") }, selection], + [ + "environment disabled", + { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [fallback.instanceId]: { driver: available.driver, enabled: false, config: {} }, + }, + }, + }, + selection, + ], + ] as const)("resolves %s", (_name, overrides, expected) => { + expect(resolveNewThreadModelSelection({ ...defaults, ...overrides }).selection).toEqual( + expected, + ); + }); + it.each([ + { enabled: false }, + { installed: false }, + { auth: { status: "unauthenticated" as const } }, + { availability: "unavailable" as const }, + { usageLimits: limited.usageLimits }, + ])("rejects an unusable fallback: %j", (overrides) => { + expect( + resolveNewThreadModelSelection({ + ...defaults, + providers: [limited, { ...available, ...overrides }], + }).selection, + ).toEqual(selection); + }); + it.each([undefined, "invalid"])("treats an unknown reset as exhausted: %s", (resetsAt) => { + expect( + exhaustedUsageWindow( + provider({ + usageLimits: { + checkedAt: limited.checkedAt, + windows: [{ ...window, usedPercent: 100, resetsAt }], + }, + }), + now, + ), + ).toBeDefined(); + }); + it.each([ + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], + ["cursor", ["autoPercentUsed"], "composer-2.5", false], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], + ["antigravity", ["gemini-weekly"], "antigravity-default", true], + ["opencode", ["go_weekly"], "opencode/big-pickle", false], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { + const defaultSelection = { ...selection, model }; + expect( + resolveNewThreadModelSelection({ + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); + }); +}); + describe("pace", () => { it("places the clock three fifths through a five-hour window with two hours left", () => { expect(elapsedShare(window, now)).toBeCloseTo(0.6); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 01e2ad59a332..3dca207a2efb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,5 +1,9 @@ import { type EnvironmentId, + type ModelSelection, + type ServerSettings, + type ProjectId, + resolveProjectProviderInstanceEnabled, type UsageLimitsReport, type ProviderInstanceId, type ProviderConsumeResetCreditInput, @@ -17,6 +21,85 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export function exhaustedUsageWindow( + provider: ServerProvider | undefined, + now: number, + model?: string, +) { + const exhausted = (provider?.usageLimits?.windows ?? []).filter( + (window) => + (window.usedPercent ?? 0) >= 100 && + (!window.resetsAt || + !Number.isFinite(Date.parse(window.resetsAt)) || + Date.parse(window.resetsAt) > now), + ); + if (model === undefined) return exhausted[0]; + switch (provider?.driver) { + case "cursor": { + const api = exhausted.find((window) => window.id === "apiPercentUsed"); + const auto = exhausted.find((window) => window.id === "autoPercentUsed"); + return /^(auto|default|grok|composer)/.test(model) ? auto && api : api; + } + case "antigravity": { + const family = model.match(/^(gemini|claude|gpt)-/)?.[1]; + return exhausted.find((window) => { + const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; + return ( + pool === undefined || + family === undefined || + pool === family || + (pool === "3p" && (family === "claude" || family === "gpt")) + ); + }); + } + case "opencode": + return exhausted.find( + (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), + ); + default: + return exhausted[0]; + } +} + +export function resolveNewThreadModelSelection(input: { + readonly defaultSelection: ModelSelection | null; + readonly explicitSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection | null; + readonly providers: readonly ServerProvider[]; + readonly now: number; + readonly settings?: ServerSettings; + readonly projectId?: ProjectId | null; +}) { + const selection = input.explicitSelection ?? input.defaultSelection; + const unchanged = { selection, fallback: null }; + if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; + const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); + const window = exhaustedUsageWindow(provider, input.now, selection.model); + const fallback = input.providers.find( + (entry) => entry.instanceId === input.fallbackSelection?.instanceId, + ); + if ( + !provider || + !window || + !fallback?.enabled || + !fallback.installed || + fallback.auth.status === "unauthenticated" || + !isProviderAvailable(fallback) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || + (input.settings && + !resolveProjectProviderInstanceEnabled( + input.settings, + input.projectId ?? null, + fallback.instanceId, + )) + ) + return unchanged; + return { + selection: input.fallbackSelection, + fallback: { provider, resetsAt: window.resetsAt ?? null }, + }; +} + export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; /** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */ From 783581d461dc72cdd8f78fc8abb4ad536dfc4547 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 19:15:50 -0700 Subject: [PATCH 31/38] fix(ci): fork PRs now resolve in workflow_run PR lookups (#26779) Co-authored-by: Claude Opus 5.5 --- .../desktop-macos-preview-publish.yml | 19 ++++++++++------- .../workflows/mobile-fingerprint-label.yml | 21 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index bd6f59bc0f0b..6e8805b7f790 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -77,16 +77,19 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the built commit instead and require exactly one open PR - // from the same head repository and branch. The build baked its + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch + // and require exactly one at the built commit. The build baked its // PR number into the version, so two candidates would mean the // asset name could belong to either. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml index 191c95327997..45e4c9f2ad42 100644 --- a/.github/workflows/mobile-fingerprint-label.yml +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -53,15 +53,18 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the checked commit. Requiring the PR's current head to be - // that commit keeps a slow run for an older push from overwriting a - // newer result. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch. + // Requiring the PR's current head to be the checked commit keeps a + // slow run for an older push from overwriting a newer result. + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && From 8298664e9e46ad8af2c8966cfb4c08a2a31898a4 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 29 Oct 2026 02:37:31 +0000 Subject: [PATCH 32/38] fix(server,cursor,opencode): refresh provider usage when a turn hits its usage limit (#26234) Co-authored-by: Claude Opus 5.5 --- .../ProviderEventIngestor.test.ts | 109 ++++++++++++++++- .../orchestration-v2/ProviderEventIngestor.ts | 21 ++++ .../ProviderSessionManager.test.ts | 5 + .../testkit/ProviderReplayHarness.ts | 5 + .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/server/managedProvider.test.ts | 111 ++++++++++++++++++ .../src/server/managedProvider.ts | 35 ++++++ packages/provider-core/src/server/snapshot.ts | 7 +- .../src/server/CursorAgentSdk.ts | 15 +++ .../src/server/adapter.test.ts | 13 +- .../provider-cursor/src/server/adapter.ts | 8 +- .../src/server/adapter.test.ts | 45 +++++++ .../provider-opencode/src/server/adapter.ts | 6 +- 13 files changed, 373 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 3fecc1c7d2d6..7f1006f1c5ec 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -46,6 +46,9 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -68,8 +71,16 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const makeLayerTest = (ingestorEventSink: Layer.Layer) => +const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), +}); +const makeLayerTest = ( + ingestorEventSink: Layer.Layer, + instances = layerEmptyInstances, +) => Layer.mergeAll( + instances, layerTestDatabase, layerTestStores, layerTestEventSink, @@ -79,6 +90,7 @@ const makeLayerTest = (ingestorEventSink: Layer.Layer + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( + Effect.provide( + Layer.mock(ProviderAdapter.ProviderAdapterV2)({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + }), + ), + ); + const instance: ProviderInstance = { + instanceId: ProviderInstanceId.make("cursor-work"), + driverKind: ProviderDriverKind.make("cursor"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("cursor"), + continuationKey: "work", + }, + enabled: true, + displayName: undefined, + orchestrationAdapter: adapter, + textGeneration: { + generateBranchName: () => Effect.die("unused"), + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + }, + snapshot: { + refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + resolveMaintenance: () => Effect.die("unused"), + applyUsageLimits: () => Effect.die("unused"), + streamChanges: Stream.empty, + }, + }; + const lookups: ProviderInstanceId[] = []; + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.sync(() => { + lookups.push(id); + return id === instance.instanceId ? instance : undefined; + }), + }); + yield* Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const input = { + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }; + for (const failureClass of ["validation_error", "usage_limit"] as const) { + const stored = yield* ingestor.ingestNormalized({ + ...input, + event: { + type: "turn.terminal", + driver: instance.driverKind, + providerThreadId: idAllocator.derive.providerThread({ + driver: instance.driverKind, + nativeThreadId: "limited", + }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: instance.driverKind, + nativeTurnId: failureClass, + }), + runOrdinal: 1, + failureItemOrdinal: 1, + status: "failed", + failure: makeProviderFailure({ + class: failureClass, + message: "Provider rejected turn.", + }), + threadDisposition: "reusable", + }, + }); + assert.equal(stored.length, 1); + if (failureClass === "validation_error") assert.deepEqual(lookups, []); + } + assert.deepEqual(lookups, [instance.instanceId]); + yield* Deferred.await(refreshed); + }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }), + ), +); + it.effect("records accepted billed turn usage once without billing the context window", () => { const recorded: Array>> = []; const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6beb4d0e5547..06f444776cb3 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -34,6 +34,7 @@ import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -326,6 +327,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -336,6 +338,8 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); const makeDomainEvent = @@ -794,6 +798,23 @@ export const layer: Layer.Layer< }, (effect, input) => effect.pipe( + Effect.tap((storedEvents) => + Effect.gen(function* () { + if ( + storedEvents.length === 0 || + input.event.type !== "turn.terminal" || + input.event.status !== "failed" || + input.event.failure.class !== "usage_limit" + ) + return; + const instance = yield* instances.getInstance(input.providerInstanceId); + if (!instance?.snapshot.refreshUsage) return; + yield* instance.snapshot.refreshUsage.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope, { startImmediately: true }), + ); + }), + ), Effect.flatMap((storedEvents) => Effect.gen(function* () { if (input.event.type !== "app_thread.created") return storedEvents; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 9ae9ddce81a9..cac8e4fbbb8e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -533,6 +534,10 @@ function layerTest(input: { const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerConfiguredEventSink, IdAllocator.layer, layerTestStores, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 4da359f74c92..afd11a9ab7ec 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -40,6 +40,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as CommandOutputRecorder from "./CommandOutputRecorder.ts"; @@ -320,6 +321,10 @@ export function layerWithRegistry( const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerStores, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d311..e1fbcd30080f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -299,6 +299,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e43..7794107824c9 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,116 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const cache = yield* Cache.makeWith( + () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + } satisfies ServerProvider; + }), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual(yield* Ref.get(calls), 1); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + return yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index f40baee525c8..41ff65b0f294 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -44,6 +44,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -60,6 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -216,6 +218,38 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), + ), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -304,6 +338,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe2..88f2c1f11384 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,6 +14,7 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 7f0decb49af3..5986d09506d0 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,6 +43,21 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +// The SDK maps backend details to codes like PRO_USER_USAGE_LIMIT or RATE_LIMITED, without a status. +const CURSOR_USAGE_LIMIT_CODE = + /^(?:resource_exhausted|rate_limited(?:_changeable)?|\w*_usage_limit|\w*_rate_limit(?:_exceeded)?)$/i; + +export function isCursorUsageLimitError(cause: unknown): boolean { + const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + if (typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); + return ( + Reflect.get(error, "name") === "RateLimitError" || + Reflect.get(error, "status") === 429 || + (typeof code === "string" && CURSOR_USAGE_LIMIT_CODE.test(code)) + ); +} + const GENERIC_SDK_ERROR_CODES = new Set(["", "error", "unknown"]); function sdkErrorReason(cause: unknown): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index d91d3bc33993..062089fffa0c 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -214,6 +214,15 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ + { + reason: "the SDK's detailed plan usage limit code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "You've hit your usage limit", code: "PRO_USER_USAGE_LIMIT" }, + }), + failure: { class: "usage_limit", message: "You've hit your usage limit" }, + }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ @@ -232,7 +241,7 @@ describe("CursorAdapterV2", () => { }, }, { - reason: "the SDK error that broke the run", + reason: "the SDK rate limit that broke the run", wait: Effect.fail( new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "run.wait", @@ -243,7 +252,7 @@ describe("CursorAdapterV2", () => { }), ), failure: { - class: "transport_error", + class: "usage_limit", message: "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 93652e492e03..72aca919b25f 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2506,7 +2506,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( cause: result.error, // makeProviderFailure never reads a cause's own text. message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }) : makeProviderFailure({ cause: transportFailure, @@ -2532,7 +2534,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 759545fff0d1..41da6bd0ff43 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -240,6 +240,51 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect("classifies APIError 429 as a usage limit", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode: 429, + isRetryable: true, + }, + }, + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "usage_limit"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index ef85a7e70b29..2a851188d076 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2643,6 +2643,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function (state): state is OpenCodeThreadState => state !== undefined, ); const message = openCodeErrorMessage(event); + const error = event.properties.error; + const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2659,8 +2661,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { failure: makeProviderFailure({ message, - code: event.properties.error?.name ?? null, - class: "provider_error", + code: error?.name ?? null, + class: usageLimit ? "usage_limit" : "provider_error", }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", From fe219c4de18f5ee37c28cbd3914a5569b1579156 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:29:07 +0530 Subject: [PATCH 33/38] fix(web): keep tool groups collapsed after a usage-limit pause (#24937) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 74 +++++++++++-------- .../components/chat/MessagesTimeline.logic.ts | 23 +++++- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3ee079809f14..8f4b15cc11f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3841,9 +3841,15 @@ it("keeps the working header in place across worktree setup handoff", () => { }); describe("failed turn transcript", () => { - it.each(["provider_error", "usage_limit"] as const)( - "keeps historical %s failures and preceding work visible without disclosures", - (failureClass) => { + it.each([ + { position: "historical", failureClass: "provider_error" }, + { position: "historical", failureClass: "usage_limit" }, + { position: "latest", failureClass: "usage_limit" }, + ] as const)( + "keeps $position $failureClass failures visible with preceding work grouped appropriately", + ({ position, failureClass }) => { + const isLatest = position === "latest"; + const commands = isLatest ? ["pwd", "ls", "false"] : ["pwd"]; const runId = RunId.make("failed-run"); const threadId = ThreadId.make("failed-thread"); const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -3880,23 +3886,24 @@ describe("failed turn transcript", () => { attachments: [], }, }, - { - position: 1, + ...commands.map((input, index): OrchestrationV2ProjectedTurnItem => ({ + position: index + 1, visibility: "local", sourceThreadId: threadId, - sourceItemId: TurnItemId.make("command"), + sourceItemId: TurnItemId.make(index === 0 ? "command" : `command-${index}`), item: { ...base, - id: TurnItemId.make("command"), + id: TurnItemId.make(index === 0 ? "command" : `command-${index}`), + ordinal: index + 1, type: "command_execution", status: "completed", - input: "pwd", + input, output: "", exitCode: 0, }, - }, + })), { - position: 2, + position: commands.length + 1, visibility: "local", sourceThreadId: threadId, sourceItemId: TurnItemId.make("failure"), @@ -3918,20 +3925,24 @@ describe("failed turn transcript", () => { timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items, optimisticMessages: [], - }).map((entry) => ({ - ...entry, - attempt: { - id: RunAttemptId.make("superseded-attempt"), - runId, - attemptOrdinal: 1, - rootNodeId: NodeId.make("superseded-root"), - status: "superseded" as const, - completedAt: null, - }, - })), + }).map((entry) => + isLatest + ? entry + : { + ...entry, + attempt: { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + completedAt: null, + }, + }, + ), latestRun: { - runId: RunId.make("newer-run"), - status: "completed", + runId: isLatest ? runId : RunId.make("newer-run"), + status: isLatest ? "failed" : "completed", startedAt: DateTime.formatIso(at), completedAt: DateTime.formatIso(at), }, @@ -3939,16 +3950,19 @@ describe("failed turn transcript", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect( - rows.some( - (row) => - row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle", - ), - ).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe( + false, + ); const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : [])); - expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]); + expect(rows.find((row) => row.kind === "work-toggle")).toEqual( + isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined, + ); + expect(work.map((entry) => entry.id)).toEqual( + isLatest ? ["failure"] : ["command", "failure"], + ); expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later."); expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z"); + if (isLatest) return; const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"), optimisticMessages: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f4b409f901b5..c26bcf51a718 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,6 +973,24 @@ function failedTimelineRunIds( return failed; } +/** Usage-limit pauses keep their tool groups; any other failure itemizes the run's work. */ +function itemizedFailedRunIds( + entries: ReadonlyArray, + failedRunIds: ReadonlySet, +) { + const usageLimited = new Set(); + const otherFailures = new Set(); + for (const entry of entries) { + const item = timelineEntryFailedItem(entry); + if (item?.runId) { + (item.failure.class === "usage_limit" ? usageLimited : otherFailures).add(item.runId); + } + } + return new Set( + [...failedRunIds].filter((runId) => !usageLimited.has(runId) || otherFailures.has(runId)), + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; @@ -1410,6 +1428,7 @@ export function deriveMessagesTimelineRows(input: { const nextRows: MessagesTimelineRow[] = []; const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( @@ -1772,7 +1791,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( isPendingApproval(timelineEntry.entry) || - (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || + (timelineEntry.entry.runId != null && itemizedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || timelineEntry.entry.tone === "error" || @@ -1798,6 +1817,8 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.sourceActivityKind === "context-compaction" || + (nextEntry.entry.itemType === "error" && + nextEntry.entry.toolLifecycleStatus === "failed") || nextEntry.entry.tone === "error" || nextEntry.entry.sourceActivityKind === "runtime.error" || nextEntry.entry.itemType === "system_notice" || From 7632c3dc7039953cc50282e1bc339e1bf11f2ffc Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:14:44 +0530 Subject: [PATCH 34/38] fix(mobile): photo-library PNGs no longer fail after resizing (#26522) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/lib/composerFiles.test.ts | 60 ++++++++++++++++++++--- apps/mobile/src/lib/composerImageFit.ts | 59 ++++++++++++++++------ 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 546c28f408f7..bdcd35c36ff7 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -124,28 +124,35 @@ describe("composer file attachments", () => { width: 4032, height: 3024, }; + type Size = { width: number; height: number }; const native = { - size: { width: 1, height: 1 }, - resizes: [] as Array<{ width?: number | null; height?: number | null }>, + size: { width: 1, height: 1 } as Size, + resizes: [] as Size[], saved: rendered as { uri: string; base64?: string }, + encode: null as ((size: Size, format: string) => { uri: string; base64?: string }) | null, }; beforeEach(() => { native.size = { width: 1, height: 1 }; native.resizes = []; native.saved = rendered; + native.encode = null; mocks.manipulate.mockReset(); mocks.release.mockReset(); - mocks.manipulate.mockImplementation(() => { + mocks.manipulate.mockImplementation((source: string | Size) => { + let size = typeof source === "string" ? native.size : source; const context = { - resize(size: { width?: number | null; height?: number | null }) { - native.resizes.push(size); + resize(next: Size) { + native.resizes.push(next); + size = next; return context; }, renderAsync: async () => ({ - ...native.size, + width: size.width, + height: size.height, release: mocks.release, - saveAsync: async () => native.saved, + saveAsync: async (options: { format: string }) => + native.encode?.(size, options.format) ?? native.saved, }), }; return context; @@ -210,6 +217,45 @@ describe("composer file attachments", () => { ]); }); + it("shrinks a fitted PNG further when re-encoding it exceeds the image limit", async () => { + const header = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header); + header.write("IHDR", 12); + header.writeUInt32BE(1668, 16); + header.writeUInt32BE(2388, 20); + native.size = { width: 1668, height: 2388 }; + // Native PNG encoding of the 2000px resample is larger than the 7 MB original. + native.encode = (size, format) => ({ + uri: `file:///cache/fitted-${size.width}x${size.height}.${format}`, + base64: Buffer.alloc(size.width * size.height * 4).toString("base64"), + }); + mocks.size.mockReturnValue(7 * 1024 * 1024); + mocks.readBase64.mockResolvedValue(header.toString("base64")); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "IMG_9280.png", mimeType: "image/png" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + const [image] = result.images; + const last = native.resizes.at(-1)!; + expect(native.resizes[0]).toEqual({ width: 1397, height: 2000 }); + expect(Math.max(last.width, last.height)).toBeLessThan(2000); + expect(image).toEqual( + expect.objectContaining({ + name: "IMG_9280.png", + mimeType: "image/png", + sizeBytes: last.width * last.height * 4, + previewUri: `file:///cache/fitted-${last.width}x${last.height}.png`, + }), + ); + expect(image!.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + expect(mocks.delete).toHaveBeenCalledWith("file:///cache/fitted-1397x2000.png"); + expect(mocks.delete).not.toHaveBeenCalledWith(image!.previewUri); + }); + it("measures the picker file instead of trusting the reported size", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, diff --git a/apps/mobile/src/lib/composerImageFit.ts b/apps/mobile/src/lib/composerImageFit.ts index fddeac277bcf..18cd93b3fe72 100644 --- a/apps/mobile/src/lib/composerImageFit.ts +++ b/apps/mobile/src/lib/composerImageFit.ts @@ -1,3 +1,4 @@ +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import { downscalableImageMimeType, fitImageAttachmentSize, @@ -6,8 +7,24 @@ import { IMAGE_DIMENSIONS_HEADER_BYTES, readImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { estimateBase64ByteSize } from "./base64"; const PHOTO_JPEG_QUALITY = 0.85; +const MAX_BYTE_LIMIT_SHRINKS = 3; + +function shrinkToByteLimit( + size: { readonly width: number; readonly height: number }, + bytes: number, +): { width: number; height: number } | null { + if (bytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) return null; + // Encoded size tracks pixel count, so scale each edge by the square root with some headroom. + const scale = Math.sqrt(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / bytes) * 0.9; + const next = { + width: Math.max(1, Math.floor(size.width * scale)), + height: Math.max(1, Math.floor(size.height * scale)), + }; + return next.width < size.width || next.height < size.height ? next : null; +} export async function renderPhoto( uri: string, @@ -16,21 +33,35 @@ export async function renderPhoto( const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator"); let image = await ImageManipulator.manipulate(uri).renderAsync(); try { - const fittedSize = fitImageAttachmentSize(image); - if (fittedSize !== null) { - const resized = await ImageManipulator.manipulate(image).resize(fittedSize).renderAsync(); - image.release(); - image = resized; - } - const saved = await image.saveAsync({ - format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, - compress: PHOTO_JPEG_QUALITY, - base64: true, - }); - if (!saved.base64) { - throw new Error("The rendered photo has no bytes."); + let size = fitImageAttachmentSize(image); + for (let shrinks = 0; ; shrinks += 1) { + if (size !== null) { + const resized = await ImageManipulator.manipulate(image).resize(size).renderAsync(); + image.release(); + image = resized; + } + const saved = await image.saveAsync({ + format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, + compress: PHOTO_JPEG_QUALITY, + base64: true, + }); + if (!saved.base64) { + throw new Error("The rendered photo has no bytes."); + } + // PNG ignores compress, so a resampled PNG can encode larger than its original. + size = + shrinks < MAX_BYTE_LIMIT_SHRINKS + ? shrinkToByteLimit(image, estimateBase64ByteSize(saved.base64)) + : null; + if (size === null) return { base64: saved.base64, uri: saved.uri }; + try { + const { File } = await import("expo-file-system"); + const discarded = new File(saved.uri); + if (discarded.exists) discarded.delete(); + } catch (error) { + console.warn("[composer-images] could not remove discarded render", error); + } } - return { base64: saved.base64, uri: saved.uri }; } finally { image.release(); } From ab1e221618724177d67ff3d2cf07e5b211a0bfc2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 29 Oct 2026 10:24:38 +0530 Subject: [PATCH 35/38] fix(server): guest links no longer return file diffs (#26538) --- .../src/guest/GuestShareService.test.ts | 51 +++++++++++++++---- apps/server/src/guest/GuestShareService.ts | 8 ++- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 31acd7bdb60b..1ef25744c8c5 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -411,7 +411,6 @@ it.effect("serves no render for an item outside the shared thread", () => }).pipe(Effect.provide(layerTest)), ); -/** A message from the owner, which opens a run for the command below. */ const ownerRun = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ @@ -430,12 +429,17 @@ const ownerRun = Effect.gen(function* () { return runs[0]!.id; }); -const writeCommand = ( +const writeItem = ( id: string, - fields: Pick< - Extract, - "status" | "output" - >, + fields: + | Pick< + Extract, + "type" | "status" | "input" | "output" + > + | Pick< + Extract, + "type" | "status" | "fileName" | "diffStr" | "oldStr" | "newStr" + >, ) => Effect.gen(function* () { const runId = yield* ownerRun; @@ -464,8 +468,6 @@ const writeCommand = ( startedAt: now, completedAt: fields.status === "running" ? null : now, updatedAt: now, - type: "command_execution", - input: "ls /work/src", ...fields, }, }, @@ -474,17 +476,40 @@ const writeCommand = ( return TurnItemId.make(id); }); -it.effect("gives the guest a command's detail and output, as the owner's row loads them", () => +it.effect("gives the guest a command's detail and output, but no file diffs", () => Effect.gen(function* () { const { guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:command", { + const command = yield* writeItem("item:command", { + type: "command_execution", status: "completed", + input: "ls /work/src", output: "/work/src/auth.ts\n", }); const detail = (yield* guestShares.item(session, command)).item; assert.deepInclude(detail, { input: "ls /work/src", output: "/work/src/auth.ts\n" }); + const edit = yield* writeItem("item:edit", { + type: "file_change", + status: "completed", + fileName: "src/auth.ts", + diffStr: "-const token = 1;\n+const token = 2;\n", + }); + const write = yield* writeItem("item:write", { + type: "file_change", + status: "completed", + fileName: "src/config.ts", + oldStr: "", + newStr: "export const key = 1;\n", + }); + for (const [id, fileName] of [ + [edit, "src/auth.ts"], + [write, "src/config.ts"], + ] as const) { + const change = (yield* guestShares.item(session, id)).item; + assert.deepInclude(change, { type: "file_change", fileName }); + assert.doesNotHaveAnyKeys(change, ["diffStr", "oldStr", "newStr"]); + } const frames = Array.from( yield* Stream.runCollect(yield* guestShares.commandOutput(session, command)), ); @@ -508,7 +533,11 @@ it.effect("ends a running command's output once the owner stops sharing", () => Effect.gen(function* () { const { orchestrator, guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:running", { status: "running" }); + const command = yield* writeItem("item:running", { + type: "command_execution", + status: "running", + input: "ls /work/src", + }); const collecting = yield* Stream.runCollect( yield* guestShares.commandOutput(session, command), ).pipe(Effect.forkChild); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 0346068bc546..347bdc11ece5 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -57,6 +57,7 @@ import { buildBoundedThreadStreamSnapshot } from "../orchestration-v2/ThreadStre import { projectDomainEventForWire, projectTurnItemForDetail, + projectTurnItemForWire, } from "../orchestration-v2/WireProjection.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -179,7 +180,7 @@ export class GuestShareService extends Context.Service< session: string, itemId: string, ) => Effect.Effect<{ readonly path: string; readonly title: string }, GuestShareAccessError>; - /** One item of the shared thread, as the owner's expanded row loads it. */ + /** One item of the shared thread, as the owner's expanded row loads it, without file diffs. */ readonly item: ( session: string, itemId: string, @@ -658,7 +659,10 @@ const make = Effect.gen(function* () { const item = Effect.fn("GuestShareService.item")(function* (session: string, itemId: string) { const { item } = yield* threadItem(session, itemId); - return { item: projectTurnItemForDetail(item) }; + return { + item: + item.type === "file_change" ? projectTurnItemForWire(item) : projectTurnItemForDetail(item), + }; }); const commandOutput = Effect.fn("GuestShareService.commandOutput")(function* ( From eb565e13abebe1a71736b4651e4131f52b1950ba Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 29 Oct 2026 10:24:48 +0530 Subject: [PATCH 36/38] fix(claude): Open diff shows Claude edits as a diff instead of raw JSON (#26529) --- apps/server/package.json | 1 + .../Adapters/ClaudeAdapterV2.test.ts | 146 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 43 +++++- pnpm-lock.yaml | 3 + 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/apps/server/package.json b/apps/server/package.json index 5abc769412d1..2735d71c6533 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -58,6 +58,7 @@ "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "character-entities": "2.0.2", + "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 47457be66099..ea5ee249a787 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8740,6 +8740,152 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { + case: "an Edit", + toolName: "Edit", + toolInput: { + file_path: "/workspace/src/math.ts", + old_string: " return a - b;", + new_string: " return a + b;", + }, + toolUseResult: { + filePath: "/workspace/src/math.ts", + oldString: " return a - b;", + newString: " return a + b;", + originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [ + " export function add(a: number, b: number) {", + "- return a - b;", + "+ return a + b;", + " }", + ], + }, + ], + userModified: false, + replaceAll: false, + }, + diffStr: + "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", + }, + { + case: "a Write that creates a file", + toolName: "Write", + toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, + toolUseResult: { + type: "create", + filePath: "/workspace/notes.md", + content: "# Notes\n\nShip it.\n", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", + }, + { + case: "a Write that creates an empty file", + toolName: "Write", + toolInput: { file_path: "/workspace/.keep", content: "" }, + toolUseResult: { + type: "create", + filePath: "/workspace/.keep", + content: "", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: undefined, + }, + ])("stores the diff panel patch for $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const toolUseId = `toolu_01${testCase.toolName}Patch`; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), + text: "Change it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${testCase.toolName}_patch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: testCase.toolName, + input: testCase.toolInput, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000793", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000794", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: testCase.toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + ); + yield* harness.awaitTerminal(1); + + const fileChange = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ); + assert.deepEqual( + fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" + ? { diffStr: fileChange.turnItem.diffStr } + : undefined, + { diffStr: testCase.diffStr }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index dd4fc8f30af9..b1273cee7206 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -106,6 +106,7 @@ import { resolveIdleCompaction, } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -2965,6 +2966,44 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +const decodeClaudeFileEditResult = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.optional(Schema.String), + filePath: Schema.String, + content: Schema.optional(Schema.String), + structuredPatch: Schema.mutable( + Schema.Array( + Schema.Struct({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.mutable(Schema.Array(Schema.String)), + }), + ), + ), + }), +); + +function claudeFileChangeText(output: ClaudeNativeToolOutput): string { + const result = + output.type === "structured_tool_use_result" + ? Option.getOrUndefined(decodeClaudeFileEditResult(output.value)) + : undefined; + if (result === undefined) return claudeNativeToolOutputText(output); + const patch = + result.structuredPatch.length === 0 && result.type === "create" && result.content !== undefined + ? structuredPatch("/dev/null", result.filePath, "", result.content) + : { + oldFileName: result.filePath, + newFileName: result.filePath, + oldHeader: undefined, + newHeader: undefined, + hunks: result.structuredPatch, + }; + return patch.hunks.length === 0 ? "" : formatPatch(patch, FILE_HEADERS_ONLY); +} + function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); if (typeof value === "object" && value !== null) { @@ -5498,7 +5537,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const outputText = itemType === "command_execution" ? claudeCommandOutputText(input.output) - : claudeNativeToolOutputText(input.output); + : itemType === "file_change" + ? claudeFileChangeText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2eabadf0a4f..7f1de3cd97bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -897,6 +897,9 @@ importers: character-entities: specifier: 2.0.2 version: 2.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.5 version: 4.0.5 From dac8b11ddc25bd5da62a367fdb6c3de777ae2986 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 22:01:08 -0700 Subject: [PATCH 37/38] chore: vouch nexxeln (#26812) Co-authored-by: Claude Opus 5.5 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a261..1c4ae817060a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa From 573f0194e3bf0d40d82c0359b1e143cc16c018b7 Mon Sep 17 00:00:00 2001 From: Tanner Sharon Date: Thu, 29 Oct 2026 00:30:04 -0500 Subject: [PATCH 38/38] fix(bots): the bot launch test provides upstream's ProviderRegistry Upstream #26164 made t3_thread_launch read providers to fall back when the default is out of usage. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/project/handlers.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 835d9b11edfa..47b4f28e9bd6 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -88,6 +88,7 @@ it.effect("marks threads a bot launches, and the threads its threads launch", () } as unknown as ThreadLaunch.ThreadLaunchResult), }), Layer.mock(Project.ProjectService)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", }),