From a65b43892f14bd6c1280296a37a11bd5234be00d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 09:16:13 -0700 Subject: [PATCH 001/377] docs(internals): add a checklist for adding a provider (#17229) Co-authored-by: Claude Opus 5.5 (1M context) --- docs/README.md | 1 + docs/internals/adding-a-provider.md | 117 ++++++++++++++++++++++++++++ docs/internals/providers.md | 2 +- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 docs/internals/adding-a-provider.md diff --git a/docs/README.md b/docs/README.md index 79392a22f8d5..a313434ba236 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Glossary](./internals/glossary.md) - [Connection runtime](./internals/connection-runtime.md) - [Providers](./internals/providers.md) +- [Adding a provider](./internals/adding-a-provider.md) - [Pull request file revisions](./internals/pull-request-file-revisions.md) - [Model classification](./internals/model-manifest.md) - [Remote environments](./internals/remote.md) diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md new file mode 100644 index 000000000000..3184f5a12669 --- /dev/null +++ b/docs/internals/adding-a-provider.md @@ -0,0 +1,117 @@ +# Adding a provider + +A provider integration is judged by whether every T3 feature behaves honestly on it, not by +whether a turn runs. This page lists the decisions and evidence a new driver needs. The +[provider constraints](./providers.md) and the +[capability system](../orchestration-v2/provider-capability-system.md) explain why these rules exist. + +## Choose the integration shape + +- **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP + spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the + spec does not cover, and then it is a small flavor over the + [shared ACP adapter](../../apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts), like Grok + and Antigravity. Never add agent-id checks to the generic registry adapter. +- **Other protocols** get a native adapter that implements + [`ProviderAdapterV2`](../../apps/server/src/orchestration-v2/ProviderAdapter.ts), like Codex, + Claude, Cursor, OpenCode, Pi, and Muse. + +Provider-specific behavior stays in the adapter and driver. Orchestration and clients read +capabilities, never the driver kind. + +## Report only what the provider does + +- **Capabilities.** Set every flag in `OrchestrationV2ProviderCapabilities` to what the provider + really does. Turn off fork, rollback, steering, or subagent support it lacks; orchestration then + falls back, for example to portable context handoff for forks. A capability left on that fails + at runtime is a bug. +- **Permission modes.** Offer only modes the provider enforces natively, through + `supportedRuntimeModes` in the provider presentation ([Grok](../../apps/server/src/provider/GrokProvider.ts) + and [Pi](../../apps/server/src/provider/PiProvider.ts) are examples). Do not imitate a missing + mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The + server runs an unoffered stored mode as Supervised + ([`RuntimePolicy.ts`](../../apps/server/src/orchestration-v2/RuntimePolicy.ts)). +- **Approval and question options.** Pass the provider's own option IDs through unchanged. Every + request needs a way to decline that the provider honors. +- **Interaction modes.** Hide the plan toggle (`showInteractionModeToggle: false`) unless the + provider's plan output becomes T3's proposed-plan card. + +## Process, account, and setup boundaries + +- **Instances.** Session state, catalogs, and credentials belong to a provider instance, not the + driver. Remove ambient credentials the instance did not configure, so two instances cannot + silently share an account or billing. +- **Status checks.** Background status and model refreshes must not open a session that can start + MCP servers, run hooks, or launch a login. Keep heavier probes behind an explicit refresh. +- **T3 MCP tools.** Inject the thread's MCP server so agents can use T3's tools, and make a turn + survive when that server is unreachable. +- **Updates.** Run an update only through the installer that provably owns the binary; otherwise + leave it manual. See [`providerMaintenance.ts`](../../apps/server/src/provider/providerMaintenance.ts). + +## Tests + +Prove adapter behavior with replay fixtures: a provider transcript replayed through the real +orchestrator, adapter, and projections. See the [testing strategy](../orchestration-v2/testing-strategy.md). + +- Record transcripts from the real provider with a recorder script in + [`apps/server/scripts`](../../apps/server/scripts) (`record-*-replay-fixture.ts`). A new protocol + usually needs its own recorder. +- Cover the shared scenarios under + [`testkit/fixtures`](../../apps/server/src/orchestration-v2/testkit/fixtures) that the provider + supports: `simple`, `multi_turn`, `queued_turn`, `turn_interrupt`, `message_steering`, + `provider_thread_resume`, and the `tool_call_*` approval cases. Add provider-specific fixtures + for behavior the shared ones do not reach. +- Unit tests are for pure logic and for failures a real provider cannot produce on demand. A test + that drives the adapter with hand-written frames is a guess about the protocol; live testing + regularly finds what those guesses missed. +- A gated live test against the real binary is welcome, but it does not replace replays, because + CI skips it. + +## Traps several providers hit + +Each of these broke more than one adapter. A replay fixture for each case your provider can +produce catches them. + +- **Settings applied before the provider advertises them.** Agents often list config options after + the session opens, in a later update, or only once a model runs (an effort option that exists + only for models with effort). An agent can accept a write before then and change nothing. Wait + for the option to be advertised before writing it, and treat a stored option the live session + does not offer as skipped, not as a failure. +- **"Default" is a sentinel, not a model.** A default-model selection must switch the session back + after a named model ran, including in a resumed session. Never send the sentinel to the + provider. +- **Stop sent before the turn started.** A cancel that reaches the provider before it registered + the prompt can be ignored while the turn runs to completion, or can leave a session the provider + never saved. Find the provider's own turn-started signal, and test a Stop pressed right after + sending. +- **Work the provider starts on its own.** Background commands, subagents, workflows, and goals can + finish or start a turn after T3's turn settled. Offer a continuation through + [`ProviderContinuationRequests`](../../apps/server/src/orchestration-v2/ProviderContinuationRequests.ts) + so the parent wakes, and report `hasPendingBackgroundWork` so the session is not released as + idle. Dropping it, or killing the session, loses the result. +- **Subagents outlive the run that launched them.** A child can report after its parent's turn + settled, or resume during a later run. Route its updates by the subagent, not by whichever run + is currently active. +- **A failed or stopped turn leaves nothing open.** Settle the turn's tool calls, subagents, and + pending approvals and questions. Each terminal path needs its own replay: completion, Stop, + provider failure, and process exit. +- **Usage limits are not generic failures.** Classify quota and rate-limit errors as `usage_limit` + with a reset time when the provider gives one, so the thread shows Limited and can resume. +- **Paths and environment.** Test a symlinked workspace, such as macOS `/tmp`; an agent that + compares canonical paths fails every turn there. Drop inherited credentials the instance did not + configure. Report unknown sign-in as unknown rather than as signed in. + +## Where a driver plugs in + +- **Contracts:** settings schema and patch, default model, and display name in + [`packages/contracts`](../../packages/contracts/src). New providers are off by default. +- **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry + in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in + the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy + in [`model-manifest.json`](../../apps/server/src/provider/model-manifest.json) (bump `updatedAt`; + see [model manifest](./model-manifest.md)), and text generation for titles and commit messages. +- **Clients:** web settings metadata and badge, provider icons on web and mobile, and settings + search terms. +- **Docs:** a `docs/user/providers-.md` guide in the product's voice, a row in the + [install](../user/install.md#providers) table, and any provider difference that changes the + [permission modes](../user/permission-modes.md) page. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ca1973438b8d..912cd3045531 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ instead of spreading provider checks through orchestration and clients. A driver kind identifies an integration; an instance identifies one configuration and account lifecycle. Route work by instance, so two accounts using the same driver do not share mutable -session or catalog state. +session or catalog state. For a new driver, start with [adding a provider](./adding-a-provider.md). ## Process and account isolation From a6ec88f7a716fc421bd22c2484881c44110f9375 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 09:17:25 -0700 Subject: [PATCH 002/377] fix(mobile): keep native screens ordered during stack pops (#17231) --- .../src/native/createV5StackNavigator.ios.tsx | 9 ++++++- .../native/workspace-stack-projection.test.ts | 26 ++++++++++++++++--- .../src/native/workspace-stack-projection.ts | 15 ++++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 4ef229495c66..413bd8712704 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -181,7 +181,14 @@ export function V5CardStackView(props: V5StackViewProps) { > - 0} /> + current.key === route.key) > 0 + : index > 0 + } + /> {descriptor.render()} diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index 7f0537653ebd..1f5c00c65551 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -143,21 +143,41 @@ describe("v5 stack handoff", () => { }); it("still retains an unfinished JS pop beside a completed native pop", () => { expect(reconcileStackScreens([home, thread, files], [home], new Set([files.key]))).toEqual([ - thread, home, + thread, ]); }); it("retains removed native screens through a pop followed immediately by a push", () => { const popped = reconcileStackScreens([home, thread, files], [home, thread]); const next = { ...files, key: "new-files" }; const screens = reconcileStackScreens(popped, [home, thread, next]); - expect(screens.map((route) => route.key)).toEqual(["files", "home", "thread", "new-files"]); + expect(screens.map((route) => route.key)).toEqual(["home", "thread", "files", "new-files"]); expect(screens.filter((route) => [home, thread, next].includes(route))).toEqual([ home, thread, next, ]); - expect(screens[0]).toBe(files); + expect(screens[2]).toBe(files); + }); + it("keeps the root and outgoing settings page in place until the JS pop finishes", () => { + const environments = { ...files, key: "environments", name: "SettingsEnvironments" }; + const popped = reconcileStackScreens([settings, environments], [settings]); + expect(popped).toEqual([settings, environments]); + expect(reconcileStackScreens(popped, [settings])).toEqual(popped); + const next = { ...environments, key: "next-environments" }; + expect(reconcileStackScreens(popped, [settings, next])).toEqual([settings, environments, next]); + expect(reconcileStackScreens(popped, [settings], new Set([environments.key]))).toEqual([ + settings, + ]); + }); + it("retains a multi-page pop in order without changing the active router order", () => { + const replacement = { ...thread, key: "replacement" }; + const screens = reconcileStackScreens([home, thread, files], [home, replacement]); + expect(screens).toEqual([home, thread, files, replacement]); + expect(screens.filter((route) => route === home || route === replacement)).toEqual([ + home, + replacement, + ]); }); it("updates params without retaining duplicate copies of the same screen", () => { const updated = { ...thread, params: { threadId: "another-thread" } }; diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 48f196cd3f18..79e66290fe6f 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -57,17 +57,18 @@ export function partitionStackPresentations( return groups; } -/** Preserve UIKit's outgoing screens, before newly pushed screens, until dismissal. */ +/** Keep outgoing screens in place until dismissal; Fabric moves enqueue native pop/push operations. */ export function reconcileStackScreens( previous: readonly T[], current: readonly T[], completedNativeDismissals: ReadonlySet = new Set(), ): T[] { const active = new Set(current.map((route) => route.key)); - return [ - ...previous.filter( - (route) => !active.has(route.key) && !completedNativeDismissals.has(route.key), - ), - ...current, - ]; + const screens = [...current]; + previous.forEach((route, index) => { + if (!active.has(route.key) && !completedNativeDismissals.has(route.key)) { + screens.splice(Math.min(index, screens.length), 0, route); + } + }); + return screens; } From 4daec109cdf8e3e92b392017d7564e735e54f6fd Mon Sep 17 00:00:00 2001 From: chise Date: Fri, 9 Oct 2026 01:04:26 +0700 Subject: [PATCH 003/377] fix(server): pairing tokens work on Node versions that cannot bind booleans (#16730) --- apps/server/src/persistence/AuthPairingLinks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index ca18088b4bf7..623fc8159db9 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -172,7 +172,7 @@ export const make = Effect.gen(function* () { proof_key_thumbprint IS NULL OR proof_key_thumbprint = ${proofKeyThumbprint} ) - AND (${requestedScopes === undefined} OR EXISTS ( + AND (${requestedScopes === undefined ? 1 : 0} OR EXISTS ( SELECT 1 FROM json_each(${JSON.stringify(requestedScopes ?? [])}) AS requested WHERE requested.value IN ( From 61b9790816a8af359ab7e8ba0b52a3481353b18d Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:05:22 -0400 Subject: [PATCH 004/377] fix(mobile): HTML pages in a thread no longer trap scrolling on Android (#17211) Co-authored-by: Claude Opus 5.5 --- .../features/threads/HtmlRenderWebView.tsx | 35 ++++-- patches/react-native-webview@14.0.1.patch | 56 +++++++++ pnpm-lock.yaml | 115 +++++++++--------- pnpm-workspace.yaml | 2 + 4 files changed, 138 insertions(+), 70 deletions(-) create mode 100644 patches/react-native-webview@14.0.1.patch diff --git a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx index 0249e36673e3..67721ef7bce7 100644 --- a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx +++ b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx @@ -51,20 +51,26 @@ function postTheme(view: WebView | null, theme: HtmlRenderTheme) { const OVERFLOW_MESSAGE_TYPE = "t3-html-render-overflow"; -// Reports whether the page overflows its frame, so a feed row only takes scroll -// gestures from a page that can use them. -const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var o=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1||Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},overflow:o}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`; +// Reports which ways the page overflows its frame, so a feed row only takes +// scroll gestures from a page that can use them. +const OVERFLOW_SCRIPT = `(function(){var last;function report(){var d=document.documentElement,b=document.body;var x=Math.max(d.scrollWidth,b?b.scrollWidth:0)>window.innerWidth+1,y=Math.max(d.scrollHeight,b?b.scrollHeight:0)>window.innerHeight+1,o=x+","+y;if(o===last)return;last=o;window.ReactNativeWebView.postMessage(JSON.stringify({type:${JSON.stringify(OVERFLOW_MESSAGE_TYPE)},x:x,y:y}));}report();if(window.ResizeObserver){var r=new ResizeObserver(report);r.observe(document.documentElement);if(document.body)r.observe(document.body);}window.addEventListener("resize",report);})();true;`; -function readOverflowMessage(data: string) { +type Overflow = { readonly x: boolean; readonly y: boolean }; + +const NO_OVERFLOW: Overflow = { x: false, y: false }; + +function readOverflowMessage(data: string): Overflow | null { try { const message: unknown = JSON.parse(data); return typeof message === "object" && message !== null && "type" in message && message.type === OVERFLOW_MESSAGE_TYPE && - "overflow" in message && - typeof message.overflow === "boolean" - ? message.overflow + "x" in message && + typeof message.x === "boolean" && + "y" in message && + typeof message.y === "boolean" + ? { x: message.x, y: message.y } : null; } catch { return null; @@ -88,7 +94,7 @@ export function HtmlRenderWebView(props: { const [initialTheme] = useState(theme); const [generation, setGeneration] = useState(0); const [loaded, setLoaded] = useState(false); - const [overflows, setOverflows] = useState(false); + const [overflow, setOverflow] = useState(NO_OVERFLOW); const webView = useRef>(null); const crashes = useRef(0); // The theme the loaded document shows; null until it loads. @@ -111,10 +117,10 @@ export function HtmlRenderWebView(props: { } shownTheme.current = null; setLoaded(false); - setOverflows(false); + setOverflow(NO_OVERFLOW); setGeneration((value) => value + 1); }; - const scrollable = !props.nested || overflows; + const scrollable = !props.nested || overflow.x || overflow.y; // Pages have no horizontal padding of their own, so full screen adds the // feed's gutter in the page's background color. return ( @@ -141,7 +147,10 @@ export function HtmlRenderWebView(props: { showsVerticalScrollIndicator={!props.nested} showsHorizontalScrollIndicator={!props.nested} scrollEnabled={scrollable} - nestedScrollEnabled={props.nested && overflows} + // Android only: the page holds vertical drags until it reaches an edge, + // then hands them to the feed (patches/react-native-webview). A page + // that only overflows sideways leaves vertical drags to the feed. + nestedScrollEnabled={props.nested && overflow.y} overScrollMode={props.nested ? "never" : "always"} // Only the page itself loads here; other top-frame navigations are // dropped. A link the reader taps opens as a new window, which the @@ -167,8 +176,8 @@ export function HtmlRenderWebView(props: { ? { injectedJavaScript: OVERFLOW_SCRIPT, onMessage: (event: WebViewMessageEvent) => { - const overflow = readOverflowMessage(event.nativeEvent.data); - if (overflow !== null) setOverflows(overflow); + const next = readOverflowMessage(event.nativeEvent.data); + if (next !== null) setOverflow(next); }, } : {})} diff --git a/patches/react-native-webview@14.0.1.patch b/patches/react-native-webview@14.0.1.patch new file mode 100644 index 000000000000..65f0f746485e --- /dev/null +++ b/patches/react-native-webview@14.0.1.patch @@ -0,0 +1,56 @@ +diff --git a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java +index 80c6803..374f824 100644 +--- a/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java ++++ b/android/src/main/java/com/reactnativecommunity/webview/RNCWebView.java +@@ -122,14 +122,50 @@ public class RNCWebView extends WebView implements LifecycleEventListener { + cleanupCallbacksAndDestroy(); + } + ++ private float touchDownX; ++ private float touchDownY; ++ private float lastTouchY; ++ private boolean multiTouch; ++ + @Override + public boolean onTouchEvent(MotionEvent event) { + if (this.nestedScrollEnabled) { +- requestDisallowInterceptTouchEvent(true); ++ requestDisallowInterceptTouchEvent(!shouldYieldToParent(event)); + } + return super.onTouchEvent(event); + } + ++ // A page that scrolls vertically hands a mostly vertical drag to the scrolling ++ // parent once it cannot scroll further that way, as nested scroll views do on iOS ++ // and the web. A page that never scrolls vertically keeps every gesture, since it ++ // may scroll inside an iframe this view cannot measure. A gesture that ever has ++ // a second finger, such as a pinch, stays with the page until every finger lifts. ++ private boolean shouldYieldToParent(MotionEvent event) { ++ switch (event.getActionMasked()) { ++ case MotionEvent.ACTION_DOWN: ++ touchDownX = event.getX(); ++ touchDownY = event.getY(); ++ lastTouchY = touchDownY; ++ multiTouch = false; ++ return false; ++ case MotionEvent.ACTION_POINTER_DOWN: ++ multiTouch = true; ++ return false; ++ case MotionEvent.ACTION_MOVE: ++ if (multiTouch) return false; ++ float dy = event.getY() - lastTouchY; ++ lastTouchY = event.getY(); ++ if (dy == 0) return false; ++ if (Math.abs(event.getY() - touchDownY) < Math.abs(event.getX() - touchDownX)) { ++ return false; ++ } ++ if (!canScrollVertically(1) && !canScrollVertically(-1)) return false; ++ return !canScrollVertically(dy > 0 ? -1 : 1); ++ default: ++ return false; ++ } ++ } ++ + @Override + protected void onSizeChanged(int w, int h, int ow, int oh) { + super.onSizeChanged(w, h, ow, oh); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14b1334c326..eed53ec3b655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,7 @@ patchedDependencies: react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2 + react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 importers: @@ -369,7 +370,7 @@ importers: version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) expo: specifier: 58.0.2 - version: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + version: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-asset: specifier: ~58.0.10 version: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) @@ -516,7 +517,7 @@ importers: version: 15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-webview: specifier: 14.0.1 - version: 14.0.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 14.0.1(patch_hash=81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-worklets: specifier: 0.13.0 version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -12788,7 +12789,7 @@ snapshots: '@clerk/shared': 4.33.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@expo/config-plugins': 58.0.4(typescript@7.0.2) base-64: 1.0.0 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-url-polyfill: 4.0.0(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -13627,7 +13628,7 @@ snapshots: compression: 1.8.1 connect: 3.7.0 dnssd-advertise: 1.1.6 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-server: 58.0.2 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -13720,13 +13721,13 @@ snapshots: dependencies: chalk: 4.1.2 optionalDependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) '@expo/dom-webview@58.0.2(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -13811,7 +13812,7 @@ snapshots: react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 optionalDependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) '@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6)': dependencies: @@ -13843,7 +13844,7 @@ snapshots: postcss: 8.5.28 resolve-from: 5.0.0 optionalDependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) transitivePeerDependencies: - bufferutil - supports-color @@ -13867,7 +13868,7 @@ snapshots: dependencies: '@expo/log-box': 58.0.8(@expo/dom-webview@58.0.2)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) anser: 1.4.10 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) pretty-format: 29.7.0 react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -13955,7 +13956,7 @@ snapshots: '@expo/router-server@58.0.6(@expo/metro-runtime@58.0.10)(expo-constants@58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2))(expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(expo-server@58.0.2)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: debug: 4.4.3 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-constants: 58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2) expo-font: 58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) expo-server: 58.0.2 @@ -13978,7 +13979,7 @@ snapshots: '@expo/ui@58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -15574,7 +15575,7 @@ snapshots: dependencies: '@t3tools/client-runtime': link:packages/client-runtime '@t3tools/shared': link:packages/shared - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-asset: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) expo-clipboard: 58.0.3(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) expo-haptics: 58.0.4(expo@58.0.2) @@ -17147,7 +17148,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-widgets: 58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' @@ -18252,12 +18253,12 @@ snapshots: expo-application@58.0.3(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-asset@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.12.2(typescript@7.0.2) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-constants: 58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18267,7 +18268,7 @@ snapshots: expo-audio@58.0.4(patch_hash=c20cb927d6848c53e7bf6ec14fe12f19daae9210165d2dbaf4e0f97ce31e8742)(expo-asset@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2))(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-asset: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18289,21 +18290,21 @@ snapshots: expo-blur@58.0.3(patch_hash=d0311b0c0b05bd94393bd9a84653cb42503d5f3c8a19eb2627c34ace08bedf18)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-build-properties@58.0.9(expo@58.0.2): dependencies: '@expo/schema-utils': 58.0.1 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) resolve-from: 5.0.0 semver: 7.8.5 expo-camera@58.0.7(@types/emscripten@1.41.5)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18311,7 +18312,7 @@ snapshots: expo-clipboard@58.0.3(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18319,7 +18320,7 @@ snapshots: dependencies: '@expo/env': 2.5.1 '@expo/require-utils': 58.0.2(typescript@7.0.2) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color @@ -18327,11 +18328,11 @@ snapshots: expo-crypto@58.0.4(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-dev-client@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-dev-launcher: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-dev-menu: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 58.0.1(expo@58.0.2) @@ -18343,72 +18344,72 @@ snapshots: expo-dev-launcher@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 58.0.1 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-dev-menu: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-manifests: 58.0.1(expo@58.0.2) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-dev-menu-interface@58.0.1(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-dev-menu@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-dev-menu-interface: 58.0.1(expo@58.0.2) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-device@58.0.5(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) ua-parser-js: 0.7.41 expo-document-picker@58.0.4(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-eas-client@58.0.2: {} expo-file-system@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-glass-effect@58.0.3(patch_hash=f750041c45ab838a3530451dc7b50d6640a79141037a1b0499c5e3412a860c57)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-haptics@58.0.4(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-image-loader@58.0.3(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-image-manipulator@58.0.11(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-image-loader: 58.0.3(expo@58.0.2) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-image-picker@58.0.10(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-image-loader: 58.0.3(expo@58.0.2) expo-image@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 @@ -18417,12 +18418,12 @@ snapshots: expo-keep-awake@58.0.3(expo@58.0.2)(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 expo-linking@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-constants: 58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2) invariant: 2.2.4 react: 19.3.0 @@ -18433,7 +18434,7 @@ snapshots: expo-manifests@58.0.1(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-json-utils: 58.0.1 expo-modules-autolinking@58.0.7(typescript@7.0.2): @@ -18464,7 +18465,7 @@ snapshots: expo-network@58.0.3(expo@58.0.2)(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 expo-notifications@58.0.11(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2): @@ -18472,7 +18473,7 @@ snapshots: '@expo/image-utils': 0.12.2(typescript@7.0.2) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-application: 58.0.3(expo@58.0.2) expo-constants: 58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2) react: 19.3.0 @@ -18483,14 +18484,14 @@ snapshots: expo-paste-input@0.1.15(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-quick-actions@6.0.2(expo@58.0.2)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.8.14(typescript@7.0.2) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) schema-utils: 4.3.3 sf-symbols-typescript: 2.2.0 transitivePeerDependencies: @@ -18499,11 +18500,11 @@ snapshots: expo-secure-store@58.0.2(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-sensors@58.0.1(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) invariant: 2.2.4 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18514,7 +18515,7 @@ snapshots: '@expo/config-plugins': 58.0.4(typescript@7.0.2) '@expo/config-types': 58.0.2 '@expo/plist': 0.10.1 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18525,7 +18526,7 @@ snapshots: dependencies: '@expo/config-plugins': 58.0.4(typescript@7.0.2) '@expo/image-utils': 0.12.2(typescript@7.0.2) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -18534,7 +18535,7 @@ snapshots: expo-sqlite@58.0.8(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: await-lock: 2.2.2 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18543,7 +18544,7 @@ snapshots: expo-symbols@58.0.3(expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-font: 58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) @@ -18551,7 +18552,7 @@ snapshots: expo-updates-interface@58.0.1(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-updates@58.0.13(expo-dev-client@58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)))(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: @@ -18563,7 +18564,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-eas-client: 58.0.2 expo-manifests: 58.0.1(expo@58.0.2) expo-structured-headers: 58.0.1 @@ -18582,20 +18583,20 @@ snapshots: expo-video@58.0.6(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-web-browser@58.0.4(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) expo-widgets@58.0.11(patch_hash=d00838b5b215a4c45f89b8b7d34878d158528756fbcf281bae1cbd6f0752deed)(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.11(@babel/core@7.29.7)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) react: 19.3.0 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -18603,7 +18604,7 @@ snapshots: - react-dom - react-native-worklets - expo@58.0.2(56fc8ddb8129740412739f0f0a6ee025): + expo@58.0.2(bd4444e61fed72e958d45f73733b7f54): dependencies: '@babel/runtime': 7.29.7 '@expo/cli': 58.1.1(@expo/dom-webview@58.0.2)(@expo/metro-runtime@58.0.10)(bufferutil@4.1.0)(expo-constants@58.0.9(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(typescript@7.0.2))(expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2)(utf-8-validate@6.0.6) @@ -18634,7 +18635,7 @@ snapshots: '@expo/dom-webview': 58.0.2(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@expo/metro-runtime': 58.0.10(@expo/log-box@58.0.8)(expo@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-dom: 19.3.0(react@19.3.0) - react-native-webview: 14.0.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + react-native-webview: 14.0.1(patch_hash=81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -21279,7 +21280,7 @@ snapshots: dependencies: react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-webview@14.0.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-webview@14.0.1(patch_hash=81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87c007312a71..c7f0a786151a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -297,6 +297,8 @@ patchedDependencies: react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + # Android: a nested WebView hands a vertical drag to its scrolling parent at the page's edge. + react-native-webview@14.0.1: patches/react-native-webview@14.0.1.patch peerDependencyRules: allowAny: From 07a8b9ece40bc20a5f84ce78d12f621518065863 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 8 Oct 2026 15:21:51 -0300 Subject: [PATCH 005/377] fix(web): centered scrollers no longer shift when the scrollbar appears (#17077) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/RightPanelTabs.tsx | 2 +- .../web/src/components/device/DevicePanel.tsx | 2 +- .../files/AttachmentFilePreview.tsx | 1 + .../src/components/files/FilePreviewPanel.tsx | 6 +- .../components/preview/PreviewEmptyState.tsx | 2 +- .../components/preview/PreviewUnreachable.tsx | 2 +- .../PullRequestsUnavailableState.tsx | 2 +- .../settings/ProviderSettingsPanel.tsx | 5 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/require-centered-scroll-gutter.ts | 152 ++++++++++++++++++ vite.config.ts | 1 + 11 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 2c7e3e3da68c..6869dd7d1ec2 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -479,7 +479,7 @@ function RightPanelEmptyState(props: { aria-label="Open a surface" data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")} className={cn( - "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", + "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", // The panel topbar sits above this container; matching bottom padding // keeps the list centered against the full panel, not the leftover. "pb-(--workspace-topbar-height)", diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index c6fdaad6f78d..7e6804043f44 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -232,7 +232,7 @@ export function DevicePanel(props: { } /> ) : ( -
+
) : kind === "image" ? ( + // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls.
) : relativePath && file.error && file.data === null ? ( -
+

{file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 35b4b2f13d64..615d7e22586f 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -48,7 +48,7 @@ export function PreviewEmptyState({ } return ( -

+
{recents.length > 0 ? (
diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index dac80f070b48..557017838373 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -21,7 +21,7 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props) const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; return ( -
+

diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index da182d268637..6c84b59fecf9 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -19,7 +19,7 @@ export function PullRequestsUnavailableState({ gitHubUrl?: string; }) { return ( - + diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index c56e5e0b5fcf..37b986217d29 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -217,7 +217,10 @@ function ProviderSettingsPlaceholder({ ) : null} {icon} diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 50fe249d9ba5..2effeefe2af6 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -12,6 +12,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts"; import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; +import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts"; import requireSuppressionReason from "./rules/require-suppression-reason.ts"; export default definePlugin({ @@ -31,6 +32,7 @@ export default definePlugin({ "no-rpc-permission-bypass": noRpcPermissionBypass, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, + "require-centered-scroll-gutter": requireCenteredScrollGutter, "require-suppression-reason": requireSuppressionReason, }, }); diff --git a/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts b/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts new file mode 100644 index 000000000000..3a6bf389234b --- /dev/null +++ b/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts @@ -0,0 +1,152 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { unwrapExpression } from "../utils.ts"; + +const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u; +// Either utility keeps the content box the same width whether or not the scrollbar shows. Both +// exemptions must be unprefixed: a variant such as lg: leaves every other state unprotected. +const RESERVES_GUTTER = + /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u; +const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u; +const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u; +const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u; +const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u; +const ROW = /(?:^|:)flex-row(?:-reverse)?$/u; +const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u; +const CENTERS_ROW_ITEMS = + /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u; + +interface ClassNames { + /** Classes that apply in at least one render. */ + readonly possible: Set; + /** Classes that apply in every render, outside any conditional branch. */ + readonly always: Set; +} + +/** + * The class names a className value can produce: string literals, template text, and the + * branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped. + */ +function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) { + const expression = unwrapExpression(node); + if (Option.isNone(expression)) return; + const value = expression.value; + + const addAll = (text: string | null) => { + for (const className of text?.split(/\s+/u) ?? []) { + if (!className) continue; + classNames.possible.add(className); + if (always) classNames.always.add(className); + } + }; + + switch (value.type) { + case "Literal": + if (typeof value.value === "string") addAll(value.value); + return; + case "TemplateLiteral": + value.quasis.forEach((quasi, index) => { + // A token touching an interpolation is only part of a class name, such as `${prefix}x`. + const tokens = (quasi.value.cooked ?? "").split(/\s+/u); + if (index > 0) tokens.shift(); + if (!quasi.tail) tokens.pop(); + addAll(tokens.join(" ")); + }); + for (const nested of value.expressions) collectClassNames(nested, classNames, always); + return; + case "JSXExpressionContainer": + collectClassNames(value.expression, classNames, always); + return; + case "ConditionalExpression": + collectClassNames(value.consequent, classNames, false); + collectClassNames(value.alternate, classNames, false); + return; + case "LogicalExpression": + collectClassNames(value.left, classNames, false); + collectClassNames(value.right, classNames, false); + return; + case "ArrayExpression": + for (const element of value.elements) collectClassNames(element, classNames, always); + return; + case "CallExpression": + for (const argument of value.arguments) collectClassNames(argument, classNames, always); + return; + } +} + +function classNamesOf(element: ESTree.JSXElement): ClassNames { + const classNames: ClassNames = { possible: new Set(), always: new Set() }; + for (const attribute of element.openingElement.attributes) { + if ( + attribute.type === "JSXAttribute" && + attribute.name.type === "JSXIdentifier" && + attribute.name.name === "className" + ) { + collectClassNames(attribute.value, classNames, true); + } + } + return classNames; +} + +const hasClass = (classNames: ReadonlySet, pattern: RegExp) => + [...classNames].some((className) => pattern.test(className)); + +/** + * Reports a native vertical scroller that centers its content without reserving the scrollbar + * lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by + * half its width whenever the content grows past the fold. + */ +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.", + }, + }, + create(context) { + const reported = new Set(); + + const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => { + if (reported.has(scroller)) return; + if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return; + reported.add(scroller); + context.report({ + node: scroller.openingElement, + message: + "This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.", + }); + }; + + return { + JSXElement(node) { + const classNames = classNamesOf(node); + const { possible } = classNames; + if (possible.size === 0) return; + + if (hasClass(possible, SCROLLS_VERTICALLY)) { + // A responsive or conditional direction can render as either axis, so check both. + const centersColumn = + hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS); + const centersRow = + (!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) && + hasClass(possible, CENTERS_ROW_ITEMS); + if (centersColumn || centersRow) report(node, classNames); + } + + if (!hasClass(possible, CENTERED_BY_MARGIN)) return; + // The nearest enclosing scroller is the one whose scrollbar moves this element. + for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) { + // An element passed as a prop renders wherever that component puts it. + if (ancestor.type === "JSXAttribute") return; + if (ancestor.type !== "JSXElement") continue; + const ancestorClassNames = classNamesOf(ancestor); + if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue; + report(ancestor, ancestorClassNames); + return; + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index b3cd99cafba7..46d61de5e963 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -173,6 +173,7 @@ export default defineConfig({ "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", "t3code/prefer-catch-tags": "error", + "t3code/require-centered-scroll-gutter": "error", "t3code/require-suppression-reason": "error", }, overrides: [ From 62a5d3720f82f2dc3ace48fcb26c90c6e3976b50 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 13:26:05 -0500 Subject: [PATCH 006/377] fix(web): distinguish thread search matches from code tints (#17263) --- apps/web/src/index.css | 9 ++- apps/web/src/themePalette.test.ts | 20 +++++-- packages/shared/src/themePalettes.ts | 88 ++++++++++++++-------------- 3 files changed, 68 insertions(+), 49 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f0374b2160a3..73e56caaf4e6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -375,8 +375,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil /* Paint find results without changing rendered markdown or copied text. */ ::highlight(t3-thread-find) { - color: var(--app-theme-search-match-foreground, var(--color-foreground)); - background-color: var(--app-theme-search-match-background, oklch(0.826487 0.157967 78.604 / 0.3)); + color: var(--app-theme-search-match-foreground, oklch(0.216164 0.028283 77.423)); + background-color: var(--app-theme-search-match-background, oklch(0.915 0.11 85)); + + @variant dark { + color: var(--app-theme-search-match-foreground, oklch(0.970151 0 0)); + background-color: var(--app-theme-search-match-background, oklch(0.55 0.1 85)); + } } ::highlight(t3-thread-find-active) { diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 02f9e0b90ce1..feede9b7ff1d 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { converter } from "culori/fn"; import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; import { @@ -91,7 +92,13 @@ describe("theme files", () => { } }); - it("keeps built-in and standard search pairs readable and distinct", () => { + it("keeps built-in and standard search pairs readable and distinct from inline code", () => { + const toOklab = converter("oklab"); + const distance = (first: string, second: string) => { + const a = toOklab(first)!; + const b = toOklab(second)!; + return Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b); + }; const palettes = [ getStandardThemeColors("light"), getStandardThemeColors("dark"), @@ -104,9 +111,14 @@ describe("theme files", () => { expect( contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground), ).toBeGreaterThanOrEqual(4.5); - expect(asHex(colors.searchMatchBackground)).not.toBe( - asHex(colors.searchMatchActiveBackground), - ); + expect( + distance(colors.searchMatchBackground, colors.searchMatchActiveBackground), + ).toBeGreaterThan(0.14); + // Check inline-code tints, code blocks, and user-message bubbles. + for (const background of [colors.muted, colors.messageSurface, colors.codeBackground]) { + expect(distance(colors.searchMatchBackground, background)).toBeGreaterThan(0.08); + expect(distance(colors.searchMatchActiveBackground, background)).toBeGreaterThan(0.08); + } } }); diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index b0eccd1b1593..d6d7979de550 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -131,10 +131,12 @@ export type ThemeDefinition = Readonly<{ * pixels the user is already seeing. Alpha-bearing tokens are flattened over * their real backdrops (canvas, or the sidebar for its rows) because theme * colors are stored as opaque OKLCH tokens. + * Search uses amber across built-ins to separate matches from accent-tinted + * inline code. The current match uses a stronger gold with dark text. */ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { - searchMatchBackground: "#fce8c0", - searchMatchForeground: "#27272a", + searchMatchBackground: "#ffde8b", + searchMatchForeground: "#21180a", searchMatchActiveBackground: "#eea52b", searchMatchActiveForeground: "#21180a", canvas: "#fcfcfc", @@ -197,7 +199,7 @@ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { }; export const T3_CODE_DARK_THEME_COLORS: ThemeColors = { - searchMatchBackground: "#533e16", + searchMatchBackground: "#8c6c1f", searchMatchForeground: "#f5f5f5", searchMatchActiveBackground: "#eea52b", searchMatchActiveForeground: "#21180a", @@ -308,10 +310,10 @@ export const T3_CHAT_THEME: ThemeDefinition = { messageActionHover: "oklch(0.539042 0.197866 0.305)", codeBackground: "oklch(0.953855 0.019695 315.668)", codeForeground: "oklch(0.445128 0.13005 307.026)", - searchMatchBackground: "oklch(0.869588 0.06751 334.899)", - searchMatchForeground: "oklch(0.325698 0.116116 325.037)", - searchMatchActiveBackground: "oklch(0.591646 0.217985 0.584)", - searchMatchActiveForeground: "oklch(1 0 0)", + searchMatchBackground: "oklch(0.915 0.11 85)", + searchMatchForeground: "oklch(0.216164 0.028283 77.423)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.928886 0.031178 322.592)", sidebarForeground: "oklch(0.396296 0.025134 285.196)", sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)", @@ -372,10 +374,10 @@ export const T3_CHAT_THEME: ThemeDefinition = { messageActionHover: "oklch(0.458754 0.184639 3.857)", codeBackground: "oklch(0.22813 0.020366 307.469)", codeForeground: "oklch(0.848703 0.064239 306.645)", - searchMatchBackground: "oklch(0.313674 0.030572 310.061)", - searchMatchForeground: "oklch(0.980735 0.004092 301.426)", - searchMatchActiveBackground: "oklch(0.460685 0.185347 4.099)", - searchMatchActiveForeground: "oklch(0.901233 0.057189 343.694)", + searchMatchBackground: "oklch(0.55 0.1 85)", + searchMatchForeground: "oklch(0.970151 0 0)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.185778 0.019368 322.159)", sidebarForeground: "oklch(0.967434 0.001326 286.375)", sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)", @@ -443,10 +445,10 @@ export const GROVE_THEME: ThemeDefinition = { messageActionHover: "oklch(0.488753 0.096536 77.829)", codeBackground: "oklch(0.955888 0.004783 158.391)", codeForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchBackground: "oklch(0.891377 0.026164 164.929)", - searchMatchForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchActiveBackground: "oklch(0.523295 0.112292 158.089)", - searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.915 0.11 85)", + searchMatchForeground: "oklch(0.216164 0.028283 77.423)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.936464 0.014601 163.554)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)", @@ -507,10 +509,10 @@ export const GROVE_THEME: ThemeDefinition = { messageActionHover: "oklch(0.815227 0.117902 84.21)", codeBackground: "oklch(0.312979 0.018942 164.082)", codeForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchBackground: "oklch(0.464636 0.066083 158.72)", - searchMatchForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchActiveBackground: "oklch(0.796228 0.133058 157.319)", - searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.55 0.1 85)", + searchMatchForeground: "oklch(0.970151 0 0)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.309925 0.032827 160.944)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)", @@ -578,10 +580,10 @@ export const OCEAN_THEME: ThemeDefinition = { messageActionHover: "oklch(0.45151 0.074407 201.516)", codeBackground: "oklch(0.957684 0.002906 253.68)", codeForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchBackground: "oklch(0.895373 0.023469 241.913)", - searchMatchForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchActiveBackground: "oklch(0.536684 0.120219 247.01)", - searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.915 0.11 85)", + searchMatchForeground: "oklch(0.216164 0.028283 77.423)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.939254 0.01193 241.729)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)", @@ -642,10 +644,10 @@ export const OCEAN_THEME: ThemeDefinition = { messageActionHover: "oklch(0.815308 0.096174 199.862)", codeBackground: "oklch(0.29661 0.021883 251.968)", codeForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchBackground: "oklch(0.439946 0.0561 243.479)", - searchMatchForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchActiveBackground: "oklch(0.758933 0.105833 241.548)", - searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.55 0.1 85)", + searchMatchForeground: "oklch(0.970151 0 0)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.290387 0.032043 247.274)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)", @@ -713,10 +715,10 @@ export const EMBER_THEME: ThemeDefinition = { messageActionHover: "oklch(0.471223 0.145843 24.688)", codeBackground: "oklch(0.959965 0.002668 47.512)", codeForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchBackground: "oklch(0.899296 0.022939 49.163)", - searchMatchForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchActiveBackground: "oklch(0.552831 0.129438 44.656)", - searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.915 0.11 85)", + searchMatchForeground: "oklch(0.216164 0.028283 77.423)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.942267 0.01151 50.785)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)", @@ -777,10 +779,10 @@ export const EMBER_THEME: ThemeDefinition = { messageActionHover: "oklch(0.775116 0.117953 29.014)", codeBackground: "oklch(0.299662 0.017229 39.973)", codeForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchBackground: "oklch(0.442681 0.0608 50.795)", - searchMatchForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchActiveBackground: "oklch(0.762174 0.124117 52.082)", - searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.55 0.1 85)", + searchMatchForeground: "oklch(0.970151 0 0)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.293349 0.029554 46.882)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)", @@ -848,10 +850,10 @@ export const IRIS_THEME: ThemeDefinition = { messageActionHover: "oklch(0.471003 0.16748 340.687)", codeBackground: "oklch(0.95997 0.004338 306.542)", codeForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchBackground: "oklch(0.897143 0.028558 299.758)", - searchMatchForeground: "oklch(0.222003 0.03479 328.979)", - searchMatchActiveBackground: "oklch(0.525348 0.15373 294.176)", - searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.915 0.11 85)", + searchMatchForeground: "oklch(0.216164 0.028283 77.423)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.941387 0.014687 300.474)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)", @@ -912,10 +914,10 @@ export const IRIS_THEME: ThemeDefinition = { messageActionHover: "oklch(0.813537 0.114101 337.23)", codeBackground: "oklch(0.281873 0.028308 295.193)", codeForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchBackground: "oklch(0.395417 0.085554 294.182)", - searchMatchForeground: "oklch(0.990339 0.008411 325.64)", - searchMatchActiveBackground: "oklch(0.671712 0.169136 293.929)", - searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.55 0.1 85)", + searchMatchForeground: "oklch(0.970151 0 0)", + searchMatchActiveBackground: "oklch(0.774202 0.151946 74.331)", + searchMatchActiveForeground: "oklch(0.216164 0.028283 77.423)", sidebar: "oklch(0.266743 0.044689 294.138)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)", From f999eb2a8e7d8db74d4f4ab7dd66f86163e1a226 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:30:47 +0200 Subject: [PATCH 007/377] fix(server): Pi extension wakes get an owned continuation turn (#17214) Co-authored-by: Claude Opus 5.5 --- .../Adapters/PiAdapterV2.test.ts | 845 +++++++++++++++++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 346 ++++++- 2 files changed, 1130 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 06728c041066..6e385f1a1489 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + CheckpointId, EnvironmentId, NodeId, ProviderInstanceId, @@ -30,8 +31,10 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../../config.ts"; +import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as IdAllocator from "../IdAllocator.ts"; import { @@ -40,7 +43,12 @@ import { type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; import { handoffBudget } from "../ContextHandoffBudget.ts"; -import { makePiAdapterV2, PI_PROVIDER } from "./PiAdapterV2.ts"; +import { + makePiAdapterV2, + PiAdapterV2Driver, + PI_PROVIDER, + type PiAdapterV2Options, +} from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; const layerServerConfig = ServerConfig.layerTest(process.cwd(), { @@ -88,7 +96,9 @@ interface FakePi { readonly resolveDeferredState: (data: unknown) => Effect.Effect; /** Reject the next `get_state` request. */ readonly failNextState: () => void; - readonly deferNextLifecycle: (type: "switch_session" | "new_session") => void; + readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; + /** Hold a model-select extension hook until its UI request is answered. */ + readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; /** Every request received by the fake process. */ @@ -291,6 +301,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { deferNextLifecycle: (type) => { deferredLifecycle = type; }, + deferNextModelSelection: () => { + deferredLifecycle = "set_model"; + }, queueModels: (value) => { models = value; }, @@ -310,22 +323,44 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } satisfies FakePi; }); -const makeAdapter = Effect.fnUntraced(function* (fake: FakePi, launchArgs = "", forkFake?: FakePi) { +const makeAdapter = Effect.fnUntraced(function* ( + fake: FakePi, + launchArgs = "", + forkFake?: FakePi, + continuationRequests?: PiAdapterV2Options["continuationRequests"], +) { const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; + const spawner = + forkFake === undefined + ? fake.spawner + : ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("--fork") + ? forkFake.spawner.spawn(command) + : fake.spawner.spawn(command), + ); + if (continuationRequests !== undefined) { + return yield* PiAdapterV2Driver.create({ + instanceId: PI_INSTANCE_ID, + displayName: undefined, + enabled: true, + environment: [], + config: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + ...continuationRequests, + take: Effect.never, + }), + ); + } return makePiAdapterV2({ instanceId: PI_INSTANCE_ID, settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, environment: {}, - spawner: - forkFake === undefined - ? fake.spawner - : ChildProcessSpawner.make((command) => - ChildProcess.isStandardCommand(command) && command.args.includes("--fork") - ? forkFake.spawner.spawn(command) - : fake.spawner.spawn(command), - ), + spawner, fileSystem, idAllocator, serverConfig, @@ -338,8 +373,9 @@ const openRuntime = Effect.fnUntraced(function* ( threadId = THREAD_ID, providerSessionId = SESSION_ID, forkFake?: FakePi, + continuationRequests?: PiAdapterV2Options["continuationRequests"], ) { - const adapter = yield* makeAdapter(fake, "", forkFake); + const adapter = yield* makeAdapter(fake, "", forkFake, continuationRequests); const runtime = yield* adapter.openSession({ threadId, providerSessionId, @@ -397,6 +433,7 @@ const startTurn = Effect.fnUntraced(function* ( selection?: ModelSelection, runOrdinal = 1, threadId = THREAD_ID, + continuation = false, ) { const appThread = yield* makeAppThread(model, threadId); const runId = RunId.make(`run:${threadId}:${runOrdinal}`); @@ -413,8 +450,8 @@ const startTurn = Effect.fnUntraced(function* ( messageId: `message:${threadId}:${runOrdinal}` as never, text, attachments, - createdBy: "user", - creationSource: "web", + createdBy: continuation ? "agent" : "user", + creationSource: continuation ? "provider" : "web", }, modelSelection: selection ?? modelSelection(model), runtimePolicy, @@ -461,6 +498,786 @@ const expectModelFailure = (errorMessage: string) => }).pipe(Effect.scoped, Effect.provide(layerTest)); describe("PiAdapterV2", () => { + it.effect.each([false, true])( + "serializes rollback fork hooks with native wake ownership, wake=%s", + (nativeWake) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + fake.deferNextLifecycle("fork"); + const rollingBack = yield* runtime + .rollbackThread({ + providerThread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-start"), + appRunOrdinal: 0, + }, + providerThreadTurns: [ + { + id: ProviderTurnId.make("previous-turn"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("previous-node"), + runAttemptId: null, + nativeTurnRef: { + driver: PI_PROVIDER, + nativeId: "user-before-wake", + strength: "strong", + }, + ordinal: 1, + status: "completed", + startedAt: providerThread.createdAt, + completedAt: providerThread.createdAt, + }, + ], + }) + .pipe(Effect.result, Effect.forkScoped); + const fork = yield* fake.takeRequest("fork"); + const overlappingTurn = yield* startTurn(runtime, providerThread).pipe(Effect.result); + assert.equal(overlappingTurn._tag, "Failure"); + const overlappingRegistration = yield* runtime + .ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }) + .pipe(Effect.result); + assert.equal(overlappingRegistration._tag, "Failure"); + yield* fake.emit({ + type: "extension_ui_request", + id: "fork-hook-dialog", + method: "confirm", + title: "Restore this branch?", + }); + const forkDialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); + assert.isTrue(forkDialog.type === "runtime_request.updated"); + if (forkDialog.type !== "runtime_request.updated") return; + assert.isNull(forkDialog.runtimeRequest.providerTurnId); + yield* runtime.respondToRuntimeRequest({ + requestId: forkDialog.runtimeRequest.id, + decision: "accept", + }); + yield* fake.takeRequest("extension_ui_response"); + if (nativeWake) { + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { + type: "text_end", + contentIndex: 0, + content: "Discarded branch wake output", + }, + }); + yield* fake.emit({ type: "agent_settled" }); + const retired = yield* takeEvent( + (event) => + event.type === "provider_session.updated" && + event.providerSession.status === "stopped", + ).pipe(Effect.as(true), Effect.raceFirst(Queue.take(offers).pipe(Effect.as(false)))); + assert.isTrue(retired, "Pi offered native work while rollback was changing its binding"); + const result = yield* Fiber.join(rollingBack); + assert.equal(result._tag, "Failure"); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + const staleTurn = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Synthetic wake", + undefined, + 1, + THREAD_ID, + true, + ).pipe(Effect.result); + assert.equal(staleTurn._tag, "Failure"); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "prompt")); + return; + } + fake.queueState({ sessionFile: "/fake/rolled-back-session.jsonl" }); + yield* fake.emit({ + type: "response", + id: fork["id"], + command: "fork", + success: true, + data: { cancelled: false }, + }); + const result = yield* Fiber.join(rollingBack); + assert.equal(result._tag, "Success"); + if (result._tag !== "Success") return; + assert.equal( + result.success.providerThread.nativeThreadRef?.nativeId, + "/fake/rolled-back-session.jsonl", + ); + yield* startTurn(runtime, result.success.providerThread); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([false, true])( + "retains native wake ownership after a rejected joining prompt, compaction aborted=%s", + (aborted) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "agent_start" }); + yield* Queue.take(offers); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const rejection = "Cannot submit a prompt while compaction is in progress."; + yield* fake.emit({ type: "response", command: "prompt", success: false, error: rejection }); + yield* fake.emit({ + type: "extension_ui_request", + id: "native-compaction-dialog", + method: "confirm", + title: "Continue native work?", + }); + const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); + assert.isTrue(dialog.type === "runtime_request.updated"); + if (dialog.type !== "runtime_request.updated") return; + assert.isNotNull(dialog.runtimeRequest.providerTurnId); + yield* runtime.respondToRuntimeRequest({ + requestId: dialog.runtimeRequest.id, + decision: "accept", + }); + yield* fake.takeRequest("extension_ui_response"); + if (aborted) { + yield* fake.emit({ type: "compaction_end", result: null, aborted: true }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === rejection, + ); + return; + } + fake.queueState({ isStreaming: true }); + yield* fake.emit({ + type: "compaction_end", + result: { summary: "Native compaction finished" }, + willRetry: true, + }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { + type: "text_end", + contentIndex: 0, + content: "Native work completed", + }, + }); + const message = yield* takeEvent((event) => event.type === "message.updated"); + assert.isTrue(message.type === "message.updated" && message.message.runId !== null); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "agent_settled" }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + terminal.type === "turn.terminal" && + terminal.status === "failed" && + terminal.failure.message === rejection, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("rejects rollback while native wake output is awaiting ownership", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + yield* Queue.take(offers); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { + type: "text_end", + contentIndex: 0, + content: "Current branch wake output", + }, + }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "ready", + ); + const rolledBack = yield* runtime + .rollbackThread({ + providerThread, + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-start"), + appRunOrdinal: 0, + }, + providerThreadTurns: [ + { + id: ProviderTurnId.make("previous-turn"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("previous-node"), + runAttemptId: null, + nativeTurnRef: { + driver: PI_PROVIDER, + nativeId: "user-before-wake", + strength: "strong", + }, + ordinal: 1, + status: "completed", + startedAt: providerThread.createdAt, + completedAt: providerThread.createdAt, + }, + ], + }) + .pipe(Effect.result); + assert.equal( + rolledBack._tag, + "Failure", + "Rollback changed the native session while its wake was still buffered", + ); + assert.isFalse(fake.allRequests().some((request) => request["type"] === "fork")); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Synthetic wake", + undefined, + 1, + THREAD_ID, + true, + ); + const message = yield* takeEvent((event) => event.type === "message.updated"); + assert.equal( + message.type === "message.updated" && message.message.text, + "Current branch wake output", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("answers model-selection dialogs before a user turn claims a native wake", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid) }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + yield* Queue.take(offers); + const requests: Array> = + []; + fake.deferNextModelSelection(); + const starting = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Continue the review", + modelSelection("xai/grok-4.6"), + ).pipe(Effect.forkScoped); + const modelRequest = yield* fake.takeRequest("set_model"); + yield* fake.emit({ + type: "extension_ui_request", + id: "model-selection-dialog", + method: "confirm", + title: "Use this model?", + }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => { + if (event.type === "runtime_request.updated") requests.push(event); + return ( + event.type === "provider_session.updated" && event.providerSession.status === "ready" + ); + }); + const dialog = requests[0]?.runtimeRequest; + assert.isDefined(dialog, "Pi buffered the dialog needed to finish model selection"); + assert.equal(requests[0]?.threadId, THREAD_ID); + assert.isNull(dialog!.providerTurnId); + yield* runtime.respondToRuntimeRequest({ requestId: dialog!.id, decision: "accept" }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["id"], "model-selection-dialog"); + assert.isTrue(response["confirmed"]); + yield* fake.emit({ + type: "response", + id: modelRequest["id"], + command: "set_model", + success: true, + data: { provider: "xai", id: "grok-4.6" }, + }); + yield* Fiber.join(starting); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Continue the review"); + assert.equal(prompt["streamingBehavior"], "steer"); + const resolved = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.id === dialog!.id, + ); + assert.isTrue( + resolved.type === "runtime_request.updated" && + resolved.runtimeRequest.status === "resolved", + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("adopts a completed native wake without sending a synthetic prompt", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + const offer = yield* Queue.take(offers).pipe( + Effect.raceFirst( + takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "error", + ).pipe( + Effect.andThen(Effect.sync(() => assert.fail("Pi killed a routable native wake"))), + ), + ), + ); + assert.equal(offer.threadId, THREAD_ID); + assert.equal(offer.providerThreadId, providerThread.id); + assert.equal(offer.delivery, "adapter_buffered"); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + let dispatchCommitted = false; + yield* offer.dispatchIfCurrent!( + Effect.sync(() => { + dispatchCommitted = true; + }), + ); + assert.isTrue(dispatchCommitted); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "Background review finished", + }, + }); + yield* fake.emit({ type: "message_end", message: { role: "assistant" } }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "ready", + ); + assert.isTrue(yield* runtime.hasPendingBackgroundWork!); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Synthetic wake", + undefined, + 1, + THREAD_ID, + true, + ); + const message = yield* takeEvent((event) => event.type === "message.updated"); + assert.equal( + message.type === "message.updated" && message.message.text, + "Background review finished", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 0); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + let dispatched = false; + yield* offer.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isFalse(dispatched); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([false, true])( + "a user prompt takes the wake without premature settlement, native wake settled=%s", + (settled) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + const offer = yield* Queue.take(offers); + if (settled) { + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "ready", + ); + } + yield* startTurn(runtime, providerThread); + const prompt = yield* fake.takeRequest("prompt"); + assert.equal(prompt["message"], "Hello pi"); + assert.equal(prompt["streamingBehavior"], "steer"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ + type: "extension_ui_request", + id: "owned-dialog", + method: "confirm", + title: "Continue?", + }); + const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); + assert.isTrue(dialog.type === "runtime_request.updated"); + if (dialog.type !== "runtime_request.updated") return; + assert.isNotNull(dialog.runtimeRequest.providerTurnId); + yield* offer.clearIfCurrent!(); + yield* runtime.respondToRuntimeRequest({ + requestId: dialog.runtimeRequest.id, + decision: "accept", + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.isTrue(response["confirmed"]); + yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); + yield* fake.emit({ + type: "message_update", + assistantMessageEvent: { + type: "text_end", + contentIndex: 0, + content: "User prompt completed", + }, + }); + yield* fake.emit({ type: "agent_settled" }); + const message = yield* takeEvent( + (event) => + event.type === "message.updated" && event.message.text === "User prompt completed", + ); + assert.isTrue(message.type === "message.updated" && message.message.runId !== null); + yield* takeEvent((event) => event.type === "turn.terminal"); + let dispatched = false; + yield* offer.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isFalse(dispatched); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("defers a wake's late settlement until the joined user prompt is acknowledged", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + yield* Queue.take(offers); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_settled" }); + yield* fake.emit({ + type: "extension_ui_request", + method: "notify", + message: "Ordering barrier", + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "notify", + ); + assert.equal(runtime.providerSession.status, "running"); + fake.queueState({ isStreaming: true }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.takeRequest("get_state"); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("settles a command-only user prompt after taking a completed wake", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + yield* Queue.take(offers); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "ready", + ); + yield* startTurn(runtime, providerThread, "default", [], "/hello"); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* takeEvent((event) => event.type === "turn.terminal"); + yield* startTurn( + runtime, + providerThread, + "default", + [], + "Stale continuation", + undefined, + 2, + THREAD_ID, + true, + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + assert.equal(fake.allRequests().filter((request) => request["type"] === "prompt").length, 1); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("keeps a pending wake on its thread and lets Stop retire its process", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + const offer = yield* Queue.take(offers); + assert.isTrue(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread)); + const otherThread = { + ...providerThread, + appThreadId: ThreadId.make("other-thread"), + id: ProviderThreadId.make("other-provider-thread"), + }; + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(otherThread)); + const failure = yield* runtime + .resumeThread({ providerThread: otherThread }) + .pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterResumeThreadError"); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: ProviderTurnId.make("settled-turn"), + requestRuntimeRestart: true, + }); + yield* takeEvent( + (event) => + event.type === "provider_session.updated" && event.providerSession.status === "stopped", + ); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + let dispatched = false; + yield* offer.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isFalse(dispatched); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each(["archive", "stop", "transport death"] as const)( + "retiring a wake by %s cancels visible dialogs and invalidates its offer", + (retirement) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const offers = + yield* Queue.unbounded(); + const { runtime, takeEvent } = yield* openRuntime( + fake, + "default", + THREAD_ID, + SESSION_ID, + undefined, + { + offer: (request) => Queue.offer(offers, request).pipe(Effect.asVoid), + }, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ type: "agent_start" }); + const offer = yield* Queue.take(offers); + yield* fake.emit({ + type: "extension_ui_request", + id: "wake-dialog", + method: "confirm", + title: "Continue background work?", + }); + const dialog = yield* takeEvent((event) => event.type === "runtime_request.updated"); + assert.isTrue(dialog.type === "runtime_request.updated"); + if (dialog.type !== "runtime_request.updated") return; + assert.isNull(dialog.runtimeRequest.providerTurnId); + if (retirement === "archive") yield* offer.clearIfCurrent!(); + else if (retirement === "stop") { + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: ProviderTurnId.make("settled-turn"), + requestRuntimeRestart: true, + }); + } else yield* fake.closeStdout; + let requestCancelled = false; + let nodeCancelled = false; + let itemCancelled = false; + yield* takeEvent((event) => { + if ( + event.type === "runtime_request.updated" && + event.runtimeRequest.id === dialog.runtimeRequest.id + ) + requestCancelled = event.runtimeRequest.status === "cancelled"; + if (event.type === "node.updated" && event.node.id === dialog.runtimeRequest.nodeId) + nodeCancelled = event.node.status === "cancelled"; + if ( + event.type === "turn_item.updated" && + event.turnItem.nodeId === dialog.runtimeRequest.nodeId + ) + itemCancelled = event.turnItem.status === "cancelled"; + return ( + event.type === "provider_session.updated" && + event.providerSession.status === + (retirement === "transport death" ? "error" : "stopped") + ); + }); + assert.isTrue(requestCancelled, "Retired wake left a pending runtime request"); + assert.isTrue(nodeCancelled); + assert.isTrue(itemCancelled); + assert.isFalse(yield* runtime.hasPendingBackgroundWork!); + let dispatched = false; + yield* offer.dispatchIfCurrent!( + Effect.sync(() => { + dispatched = true; + }), + ); + assert.isFalse(dispatched); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("stops provider-initiated work that has no T3 turn owner", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 9270253d5c74..a319b4b2f0b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -48,6 +48,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -72,6 +73,7 @@ import { import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; import * as IdAllocator from "../IdAllocator.ts"; import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -228,6 +230,11 @@ export interface PiAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly continuationRequests?: { + readonly offer: ( + request: ProviderContinuationRequests.ProviderContinuationRequest, + ) => Effect.Effect; + }; } /** Concatenate the `text` fields of a Pi content-block array. */ @@ -313,6 +320,8 @@ interface ActivePiTurn { * the turn instead. */ sawAgentActivity: boolean; + /** A user prompt joined provider-native work whose settlement may already be queued. */ + adoptedWake: boolean; /** Only slash-command prompts can complete without starting an agent run. */ readonly promptMayBeCommandOnly: boolean; /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ @@ -329,6 +338,8 @@ interface ActivePiTurn { activeCompaction: PiCompactionState | null; activeProviderRetry: PiProviderRetryState | null; failure: ReturnType | null; + /** Native retry can recover its work without accepting a rejected joining prompt. */ + rejectedPromptFailure: ReturnType | null; /** Session-tree refs read just before Stop terminates Pi, when no read is possible later. */ stopTreeRefs?: PiTurnTreeRefs | null; } @@ -366,6 +377,11 @@ interface PiThreadState { activeTurn: ActivePiTurn | null; } +interface PiWake { + readonly state: PiThreadState; + readonly events: Array; +} + // ── adapter ─────────────────────────────────────────────────── export function makePiAdapterV2( @@ -476,10 +492,26 @@ export function makePiAdapterV2( // Keep that intent beyond turn finalization so the later stdout close is // not mistaken for an unexpected transport failure. let stopRequested = false; - // Pi extensions can trigger an agent turn after the owning T3 turn has - // settled. Until orchestration has a first-class provider-initiated run, - // stop that runtime before it can execute tools without a timeline owner. + // Native work is held until an orchestrator-owned continuation or user + // turn attaches. The guard remains for sessions with no routable owner. let unsolicitedActivityDetected = false; + let pendingWake: PiWake | null = null; + let rollbackBarrier: Deferred.Deferred< + void, + ProviderAdapter.ProviderAdapterProtocolError + > | null = null; + let closed = false; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + closed = true; + pendingWake = null; + if (rollbackBarrier !== null) + yield* Deferred.fail( + rollbackBarrier, + protocolError("Pi session closed during rollback"), + ); + }), + ); let appliedModel: string | null = null; let appliedThinking: string | null = null; /** Last thread title synced into pi's session name (`/resume` listing). */ @@ -1437,7 +1469,7 @@ export function makePiAdapterV2( const tokenUsage = readUsage ? yield* readTokenUsage(turn.latestCompactionAfterTokens, completedAt) : undefined; - const failure = turn.interrupted ? null : turn.failure; + const failure = turn.interrupted ? null : (turn.rejectedPromptFailure ?? turn.failure); yield* emit({ type: "provider_turn.updated", driver: PI_PROVIDER, @@ -1554,11 +1586,74 @@ export function makePiAdapterV2( ); }; + const stopPendingWake = Effect.fnUntraced(function* (wake: PiWake) { + if (pendingWake !== wake || threadState !== wake.state || closed) return; + pendingWake = null; + stopRequested = true; + yield* cancelPendingPrompts(yield* DateTime.now); + yield* connection.terminate; + }); + const handleSessionEvent = Effect.fnUntraced(function* (event: PiRpcRecord) { + // Model-selection hooks can await a dialog before startTurn can claim + // a wake. Keep interactive requests session-scoped until a turn owns + // them; buffer native output and notifications for that turn. + const interactiveUiRequest = + event["type"] === "extension_ui_request" && + ["select", "confirm", "input", "editor"].includes(String(event["method"])); + if ( + threadState?.activeTurn === null && + pendingWake !== null && + !interactiveUiRequest && + !String(event["type"]).startsWith("t3.") && + event["type"] !== "response" + ) { + pendingWake.events.push(event); + if (event["type"] === "agent_start") yield* updateProviderSession("running", null); + if (event["type"] === "agent_settled") yield* updateProviderSession("ready", null); + return; + } const state = threadState; const turn = state?.activeTurn ?? null; switch (event["type"]) { case "agent_start": { + if ( + turn === null && + state?.providerThread.appThreadId != null && + options.continuationRequests !== undefined && + !closed && + !stopRequested + ) { + const wake: PiWake = { state, events: [event] }; + pendingWake = wake; + if (rollbackBarrier !== null) { + yield* stopPendingWake(wake); + return; + } + yield* updateProviderSession("running", null); + yield* options.continuationRequests + .offer({ + threadId: state.providerThread.appThreadId, + providerThreadId: state.providerThread.id, + driver: PI_PROVIDER, + detail: null, + delivery: "adapter_buffered", + dispatchIfCurrent: (dispatch) => + sessionEventPermit.withPermits(1)( + Effect.suspend(() => + pendingWake === wake && threadState === state && !closed + ? dispatch.pipe( + Effect.map(Option.some), + Effect.tapError(() => stopPendingWake(wake)), + ) + : Effect.succeed(Option.none()), + ), + ), + clearIfCurrent: () => sessionEventPermit.withPermits(1)(stopPendingWake(wake)), + }) + .pipe(Effect.tapError(() => stopPendingWake(wake))); + return; + } if (turn === null) { unsolicitedActivityDetected = true; yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); @@ -1787,6 +1882,17 @@ export function makePiAdapterV2( if (turn !== null) { turn.settleWhenIdle = true; turn.settleProbeGeneration += 1; + // A wake can settle while the newly joined user prompt is still + // expanding. Its acknowledgement opens the idle-probe barrier. + if ( + turn.adoptedWake && + pendingPromptResponses.some( + (pending) => + pending.providerTurnId === turn.providerTurn.id && + pending.kind === "turn_start", + ) + ) + return; yield* scheduleSettleProbe(turn, true); } return; @@ -1845,10 +1951,11 @@ export function makePiAdapterV2( if ( pendingPrompt?.kind === "turn_start" && responseTurn !== null && - responseTurn.promptMayBeCommandOnly && - !responseTurn.sawAgentActivity + ((responseTurn.promptMayBeCommandOnly && !responseTurn.sawAgentActivity) || + (responseTurn.adoptedWake && + (responseTurn.settleWhenIdle || responseTurn.promptMayBeCommandOnly))) ) { - yield* scheduleSettleProbe(responseTurn); + yield* scheduleSettleProbe(responseTurn, responseTurn.adoptedWake); } return; } @@ -1869,11 +1976,18 @@ export function makePiAdapterV2( ? turn : null; if (failedTurn !== null) { - failedTurn.failure = makeProviderFailure({ + const failure = makeProviderFailure({ message: recordString(event, "error") ?? "Pi rejected the prompt.", class: "provider_error", }); - if (state !== null) yield* finalizeTurn(state); + if (failedTurn.adoptedWake && command === "prompt") { + failedTurn.rejectedPromptFailure = failure; + failedTurn.settleWhenIdle = true; + yield* scheduleSettleProbe(failedTurn, true); + } else { + failedTurn.failure = failure; + if (state !== null) yield* finalizeTurn(state); + } } return; } @@ -1886,6 +2000,11 @@ export function makePiAdapterV2( } return; } + case "t3.rollback_barrier": { + if (rollbackBarrier !== null && event["barrier"] === rollbackBarrier) + yield* Deferred.succeed(rollbackBarrier, undefined); + return; + } case "t3.settle_probe": { // New work increments the generation before the pump can consume // a stale idle snapshot, so only a current snapshot may settle. @@ -1946,6 +2065,13 @@ export function makePiAdapterV2( // closes the provider stream cleanly; only an unexpected death // is surfaced as an event-stream failure. const state = threadState; + if (rollbackBarrier !== null) + yield* Deferred.fail( + rollbackBarrier, + protocolError("Pi transport closed during rollback"), + ); + const hadPendingWake = pendingWake !== null; + pendingWake = null; const interrupted = state?.activeTurn?.interrupted === true; if (state?.activeTurn != null) { state.activeTurn.failure = interrupted @@ -1956,6 +2082,8 @@ export function makePiAdapterV2( class: "transport_error", }); yield* finalizeTurn(state, false); + } else if (hadPendingWake) { + yield* cancelPendingPrompts(yield* DateTime.now); } if (unsolicitedActivityDetected) { yield* updateProviderSession("error", PI_UNSOLICITED_ACTIVITY_ERROR); @@ -1999,9 +2127,26 @@ export function makePiAdapterV2( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { + if (rollbackBarrier !== null) + return yield* protocolError("Cannot register a Pi thread during rollback"); if (threadState !== null && threadState.activeTurn !== null) { return yield* protocolError("Cannot register a Pi thread while a turn is active"); } + if (pendingWake !== null) { + const state = threadState; + if ( + state !== null && + state === pendingWake.state && + state.providerThread.appThreadId === threadInput.threadId && + (threadInput.existingProviderThread === undefined || + threadInput.existingProviderThread.nativeThreadRef?.nativeId === + state.providerThread.nativeThreadRef?.nativeId) + ) + return state.providerThread; + return yield* protocolError( + "Cannot switch Pi threads while native work is awaiting a turn", + ); + } const existing = threadInput.existingProviderThread; const resumeId = existing?.nativeThreadRef?.nativeId; const needsNewSession = resumeId == null && registrationAttempted; @@ -2219,6 +2364,11 @@ export function makePiAdapterV2( return sessionEntity; }, events: Stream.fromQueue(events), + hasPendingBackgroundWork: Effect.sync(() => pendingWake !== null), + hasPendingBackgroundWorkForThread: (providerThread) => + Effect.sync( + () => pendingWake !== null && pendingWake.state.providerThread.id === providerThread.id, + ), getModelContextWindow: (selection) => { if (selection.instanceId !== options.instanceId) return undefined; const slug = @@ -2267,6 +2417,8 @@ export function makePiAdapterV2( }), startTurn: (turnInput) => Effect.gen(function* () { + if (rollbackBarrier !== null) + return yield* protocolError("Cannot start a Pi turn during rollback"); const state = threadState; if (state === null) { return yield* protocolError("Pi session has no registered thread"); @@ -2284,8 +2436,10 @@ export function makePiAdapterV2( } // The orchestrator adopts a fork under its already-allocated row. // Future session updates must retain that authoritative identity. - state.providerThread = turnInput.providerThread; - yield* applySelection(turnInput.modelSelection); + const continuation = + turnInput.message.createdBy === "agent" && + turnInput.message.creationSource === "provider"; + if (!continuation) yield* applySelection(turnInput.modelSelection); // Mirror the thread title into pi's session name so the session // stays identifiable in pi's own /resume listing. Best-effort: // naming must never block a turn. @@ -2307,9 +2461,11 @@ export function makePiAdapterV2( // extension's before_agent_start system-prompt hook, never by // wrapping the user text: a wrapped first message would no // longer start with "/" and slash commands would stop expanding. - const compactCommand = parsePiCompactCommand(turnInput.message.text); + const compactCommand = continuation + ? null + : parsePiCompactCommand(turnInput.message.text); const payload = - compactCommand === null + !continuation && compactCommand === null ? yield* resolvePromptPayload(turnInput.message.text, turnInput.message.attachments) : null; const startedAt = yield* DateTime.now; @@ -2340,6 +2496,7 @@ export function makePiAdapterV2( toolStartedAt: new Map(), interrupted: false, sawAgentActivity: false, + adoptedWake: false, promptMayBeCommandOnly: compactCommand !== null || (payload?.message.trimStart().startsWith("/") ?? false), latestCompactionAfterTokens: null, @@ -2351,13 +2508,32 @@ export function makePiAdapterV2( activeCompaction: null, activeProviderRetry: null, failure: null, + rejectedPromptFailure: null, }; // Only the install/send/start-event boundary excludes the event // pump. Earlier correlated requests must leave the pump free so // project trust, login, and session-switch dialogs can be shown // and answered instead of deadlocking the caller. yield* Effect.gen(function* () { + if ( + threadState !== state || + state.activeTurn !== null || + closed || + stopRequested || + rollbackBarrier !== null || + state.providerThread.nativeThreadRef?.nativeId !== + turnInput.providerThread.nativeThreadRef?.nativeId + ) { + return yield* protocolError("Pi turn ownership changed before it could start"); + } + const wake = pendingWake?.state === state ? pendingWake : null; + if (wake !== null && wake.state.providerThread.appThreadId !== turnInput.threadId) { + return yield* protocolError("Pi native wake belongs to a different app thread"); + } + pendingWake = null; + state.providerThread = turnInput.providerThread; state.activeTurn = activeTurn; + activeTurn.adoptedWake = wake !== null && !continuation; if (compactCommand !== null) { yield* connection.send(compactRpcRecord(compactCommand)); pendingCompactResponses.push({ @@ -2368,6 +2544,7 @@ export function makePiAdapterV2( yield* connection.send({ type: "prompt", message: payload.message, + ...(wake === null ? {} : { streamingBehavior: "steer" }), ...(payload.images.length === 0 ? {} : { images: payload.images }), }); pendingPromptResponses.push({ @@ -2387,6 +2564,18 @@ export function makePiAdapterV2( lastRunOrdinal: turnInput.runOrdinal, }); yield* updateProviderSession("running", null); + if (wake !== null) { + for (const event of wake.events) { + if (!continuation && event["type"] === "agent_settled") { + activeTurn.settleWhenIdle = true; + continue; + } + yield* handleSessionEvent(event); + } + } else if (continuation) { + // A user turn, teardown, or newer binding already took the wake. + yield* finalizeTurn(state); + } if (outOfTurnExtensionErrors.length > 0) { yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); } @@ -2478,7 +2667,11 @@ export function makePiAdapterV2( const turn = threadState?.activeTurn ?? null; // Stop on a settled turn: Pi runs nothing between prompts, so // nothing of that turn is left to stop. - if (turn === null && interruptInput.requestRuntimeRestart === true) return; + if (turn === null && interruptInput.requestRuntimeRestart === true) { + if (pendingWake !== null) + yield* sessionEventPermit.withPermits(1)(stopPendingWake(pendingWake)); + return; + } if (turn === null || turn.providerTurn.id !== interruptInput.providerTurnId) { return yield* protocolError(`Pi turn ${interruptInput.providerTurnId} is not active`); } @@ -2662,6 +2855,9 @@ export function makePiAdapterV2( if (state.activeTurn !== null) { return yield* protocolError("Cannot roll back while a Pi turn is active"); } + if (pendingWake !== null) { + return yield* protocolError("Cannot roll back while native Pi work awaits a turn"); + } // `fork(entryId)` re-roots the active branch before that user // message, so the rollback boundary is the first user entry of // the earliest turn being discarded. @@ -2673,42 +2869,94 @@ export function makePiAdapterV2( if (forkEntryId === undefined) { return yield* protocolError("Pi rollback target has no captured session-tree entry"); } - const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); - if (recordField(forkData, "cancelled") === true) { - return yield* protocolError("A Pi extension cancelled the session fork"); - } - // Pi fork replaces the session file, including for rollback. Persist - // its new identity before any later request can fail or restart. - // An interrupted read leaves the identity just as unknown as a failed one. - const forkState = yield* request({ type: "get_state" }).pipe( - Effect.onError(() => - Effect.sync(() => { - threadState = null; + return yield* Effect.acquireUseRelease( + sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + if ( + rollbackBarrier !== null || + threadState !== state || + state.activeTurn !== null || + pendingWake !== null || + closed || + stopRequested + ) + return yield* protocolError( + "Pi rollback ownership changed before its fork could start", + ); + const barrier = yield* Deferred.make< + void, + ProviderAdapter.ProviderAdapterProtocolError + >(); + rollbackBarrier = barrier; + return barrier; }), ), + (barrier) => + Effect.gen(function* () { + const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); + if (recordField(forkData, "cancelled") === true) + return yield* protocolError("A Pi extension cancelled the session fork"); + const forkState = yield* request({ type: "get_state" }).pipe( + Effect.onError(() => + Effect.sync(() => { + threadState = null; + }), + ), + ); + const forkSessionFile = recordString(forkState, "sessionFile"); + if (forkSessionFile === undefined) { + threadState = null; + return yield* protocolError("Pi fork did not return a persisted session file"); + } + const entriesData = yield* request({ type: "get_entries" }).pipe( + Effect.orElseSucceed(() => undefined), + ); + const leafId = recordString(entriesData, "leafId") ?? null; + // RPC responses bypass the pump. Drain preceding native events + // before deciding whether this fork can replace their binding. + const queued = yield* Queue.offer(connection.events, { + type: "t3.rollback_barrier", + barrier, + }); + if (!queued) return yield* protocolError("Pi transport closed during rollback"); + yield* Deferred.await(barrier); + return yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + if (pendingWake !== null) { + yield* stopPendingWake(pendingWake); + return yield* protocolError( + "Native Pi work started while rollback was changing the session", + ); + } + if ( + threadState !== state || + state.activeTurn !== null || + closed || + stopRequested + ) + return yield* protocolError( + "Pi rollback ownership changed during its fork", + ); + lastNativeThreadId = forkSessionFile; + appliedModel = null; + appliedThinking = null; + appliedSessionName = null; + lastKnownLeaf = leafId; + leafCursorStale = entriesData === undefined; + yield* updateProviderThread(state, { + nativeThreadRef: providerRef(forkSessionFile), + nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), + }); + rollbackBarrier = null; + return piThreadSnapshot(state.providerThread); + }), + ); + }).pipe(Effect.onInterrupt(() => connection.terminate)), + (barrier) => + Effect.sync(() => { + if (rollbackBarrier === barrier) rollbackBarrier = null; + }), ); - const forkSessionFile = recordString(forkState, "sessionFile"); - if (forkSessionFile === undefined) { - threadState = null; - return yield* protocolError("Pi fork did not return a persisted session file"); - } - lastNativeThreadId = forkSessionFile; - appliedModel = null; - appliedThinking = null; - appliedSessionName = null; - yield* updateProviderThread(state, { nativeThreadRef: providerRef(forkSessionFile) }); - const entriesData = yield* request({ type: "get_entries" }).pipe( - Effect.orElseSucceed(() => undefined), - ); - const leafId = recordString(entriesData, "leafId") ?? null; - lastKnownLeaf = leafId; - // The fork re-baselined the tree, so the cursor is trustworthy - // again unless this listing itself failed. - leafCursorStale = entriesData === undefined; - yield* updateProviderThread(state, { - nativeConversationHeadRef: leafId === null ? null : providerRef(leafId), - }); - return piThreadSnapshot(state.providerThread); }).pipe( Effect.mapError( (cause) => @@ -2971,6 +3219,7 @@ export const PiAdapterV2Driver: ProviderAdapterDriver @@ -3005,6 +3255,7 @@ const layer: Layer.Layer Date: Thu, 8 Oct 2026 20:34:44 +0200 Subject: [PATCH 008/377] fix(server): Pi discovers optional T3 tools on demand (#17220) --- .../Adapters/piT3McpExtensionSource.test.ts | 302 ++++++++++++++++++ .../Adapters/piT3McpExtensionSource.ts | 85 +++-- .../Adapters/piT3McpInjection.test.ts | 1 + .../orchestrator-mcp-server.md | 19 +- 4 files changed, 378 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts index 9dc9fa4526e6..5498d2afec83 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts @@ -9,6 +9,308 @@ type RequestHook = ( ctx: { model: { provider: string } }, ) => Record | undefined; +interface RegisteredTool { + readonly name: string; + readonly description: string; + readonly parameters: unknown; + readonly exposure?: string; + readonly promptSnippet?: string; + readonly promptGuidelines?: ReadonlyArray; + readonly execute: ( + id: string, + args: Record, + signal?: AbortSignal, + ) => Promise<{ + readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; + }>; +} + +type AgentStartHook = ( + event: { systemPrompt: string }, + ctx: { ui: { notify: (message: string, severity: string) => void } }, +) => Promise<{ systemPrompt: string }>; + +async function loadMcpBridge( + options: { + readonly modern?: boolean; + readonly toolSearchAvailable?: boolean; + readonly toolSearchDisabled?: boolean; + readonly allowsTool?: (name: string) => boolean; + } = {}, +) { + const handlers = new Map(); + const tools: RegisteredTool[] = []; + const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; + let activeTools = ["read"]; + const transports: Array<{ + readonly url: string; + readonly authorization: string; + readonly signal: AbortSignal | undefined; + }> = []; + const servers: Array<{ readonly name: string; readonly config: Record }> = []; + const catalog = [ + { name: "orchestrator_capabilities", description: "Discover available providers and models." }, + { name: "delegate_task", description: "Delegate work to another agent." }, + { name: "task_status", description: "Check delegated work." }, + { name: "preview_snapshot", description: "Inspect the collaborative browser." }, + ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { + env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, + }, + AbortSignal, + Type: { Unsafe: (schema: unknown) => schema }, + fetch: async ( + url: string, + options: { body: string; headers: Record; signal?: AbortSignal }, + ) => { + transports.push({ + url, + authorization: options.headers.authorization!, + signal: options.signal, + }); + const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; + requests.push(request); + const result = + request.method === "tools/list" + ? { tools: catalog } + : request.method === "tools/call" + ? { content: [{ type: "text", text: "browser snapshot" }] } + : {}; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { + headers: { "content-type": "application/json" }, + }); + }, + pi: { + on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), + registerTool: (tool: RegisteredTool) => { + if (options.allowsTool && !options.allowsTool(tool.name)) return; + const index = tools.findIndex((current) => current.name === tool.name); + if (index === -1) tools.push(tool); + else tools[index] = tool; + }, + getActiveTools: () => activeTools, + setActiveTools: (names: string[]) => { + activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); + }, + getAllTools: () => + options.toolSearchAvailable && + !options.toolSearchDisabled && + (options.allowsTool?.("tool_search") ?? true) + ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] + : [], + ...(options.modern + ? { + registerMcpServer: (name: string, config: Record) => + servers.push({ name, config }), + unregisterMcpServer: () => servers.splice(0), + } + : {}), + }, + }); + return { + handlers, + tools, + requests, + servers, + transports, + getActiveTools: () => activeTools, + restoreActiveTools: (names: string[]) => { + activeTools = names; + }, + }; +} + +describe("Pi MCP tool exposure", () => { + it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { + const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + const start = bridge.handlers.get("before_agent_start"); + assert.isDefined(start); + const prompt = await start!( + { systemPrompt: "Pi system prompt" }, + { ui: { notify: () => undefined } }, + ); + assert.include(prompt.systemPrompt, "orchestrator_capabilities"); + assert.equal(bridge.servers.length, 0); + assert.equal(bridge.tools.length, 8); + assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]); + assert.deepEqual( + bridge.tools + .filter((tool) => tool.exposure !== "hidden") + .map((tool) => [tool.name, tool.exposure]), + [ + ["mcp__t3-code__orchestrator_capabilities", "direct"], + ["mcp__t3-code__delegate_task", "direct"], + ["mcp__t3-code__task_status", "direct"], + ["mcp__t3-code__preview_snapshot", "deferred"], + ], + ); + assert.deepEqual( + bridge.tools.filter((tool) => tool.exposure === "hidden").map((tool) => tool.name), + [ + "mcp__t3_code__orchestrator_capabilities", + "mcp__t3_code__delegate_task", + "mcp__t3_code__task_status", + "mcp__t3_code__preview_snapshot", + ], + ); + const result = await bridge.tools + .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")! + .execute("call-1", { depth: 2 }); + assert.equal(result.content[0]?.text, "browser snapshot"); + assert.equal(bridge.requests.at(-1)?.method, "tools/call"); + }); + + it.each(["legacy Pi", "disabled tool search"])( + "keeps tool execution available with %s", + async (mode) => { + const bridge = await loadMcpBridge({ + modern: mode !== "legacy Pi", + toolSearchAvailable: mode === "disabled tool search", + toolSearchDisabled: mode === "disabled tool search", + }); + if (mode !== "legacy Pi") { + const start = bridge.handlers.get("session_start"); + await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } }); + } + assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4); + assert.isTrue( + bridge.tools + .filter((tool) => tool.exposure !== "hidden") + .every((tool) => tool.exposure === undefined || tool.exposure === "direct"), + ); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); + assert.isDefined(tool); + const controller = new AbortController(); + const result = await tool!.execute("call-1", { depth: 2 }, controller.signal); + assert.equal(result.content[0]?.text, "browser snapshot"); + assert.strictEqual(bridge.transports.at(-1)?.signal, controller.signal); + assert.equal(bridge.transports.at(-1)?.url, "http://fixture.invalid/mcp"); + assert.equal(bridge.transports.at(-1)?.authorization, "Bearer fixture-token"); + assert.deepEqual(JSON.parse(JSON.stringify(bridge.requests.at(-1))), { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "preview_snapshot", arguments: { depth: 2 } }, + }); + assert.isUndefined(tool?.promptSnippet); + assert.isUndefined(tool?.promptGuidelines); + }, + ); + + it("preserves legacy wildcard tool selection", async () => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + allowsTool: (name) => + name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"), + }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + assert.equal(bridge.tools.length, 4); + assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot"); + assert.isDefined(tool); + assert.equal((await tool!.execute("selected", {})).content[0]?.text, "browser snapshot"); + }); + + it.each([false, true])( + "reconciles tree loadouts while honoring search exclusion: %s", + async (excludeSearch) => { + const bridge = await loadMcpBridge({ + modern: true, + toolSearchAvailable: true, + allowsTool: (name) => + name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"), + }); + await bridge.handlers.get("session_start")!( + { systemPrompt: "" }, + { ui: { notify: () => undefined } }, + ); + bridge.restoreActiveTools([ + "read", + "mcp__t3-code__task_status", + "mcp__t3-code__preview_snapshot", + ]); + const tree = bridge.handlers.get("session_tree"); + assert.isDefined(tree); + await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } }); + assert.deepEqual(bridge.getActiveTools(), [ + "read", + "mcp__t3-code__task_status", + "mcp__t3-code__preview_snapshot", + ...(!excludeSearch ? ["tool_search"] : []), + ]); + assert.isFalse( + bridge.tools.some( + (tool) => tool.exposure !== "hidden" && tool.name.endsWith("__delegate_task"), + ), + ); + }, + ); +}); + +describe("Pi tool discovery permissions", () => { + it("allows discovery without confirmation and still gates the discovered tool", async () => { + type ToolCallHook = ( + event: { toolName: string; input: unknown }, + ctx: { ui: { confirm: (title: string, detail: string) => Promise } }, + ) => Promise<{ block: true; reason: string } | undefined>; + let toolCall: ToolCallHook | undefined; + let searchPath = "builtin:tool-search"; + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { env: { T3_PI_RUNTIME_MODE: "approval-required" } }, + pi: { + on: (name: string, handler: ToolCallHook) => { + if (name === "tool_call") toolCall = handler; + }, + getAllTools: () => [{ name: "tool_search", sourceInfo: { path: searchPath } }], + }, + }); + assert.isDefined(toolCall); + const confirmations: string[] = []; + const ctx = { + ui: { + confirm: async (title: string) => { + confirmations.push(title); + return false; + }, + }, + }; + assert.isUndefined( + await toolCall!({ toolName: "tool_search", input: { query: "preview_snapshot" } }, ctx), + ); + assert.equal(confirmations.length, 0); + const result = await toolCall!({ toolName: "mcp__t3-code__preview_snapshot", input: {} }, ctx); + assert.equal(result?.block, true); + assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]); + + // An extension that replaces the search builtin is not known to be read-only. + searchPath = "/extensions/custom-search.ts"; + const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx); + assert.equal(replaced?.block, true); + assert.equal(confirmations.at(-1), "Allow tool_search?"); + }); +}); + async function loadRequestHook(): Promise { const handlers = new Map(); // Execute the shipped extension with MCP disabled; this path needs no Typebox. diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts index bca79ce6fde1..d5c9f0669125 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -1,9 +1,9 @@ /** * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. * - * Pi core has no MCP client. This file is TypeScript that Pi itself loads via - * `--extension`. It is written to a cache path at session open so packaged - * AppImage builds do not need a sibling .ts file next to the bundled server. + * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions + * keep them directly available. Pi loads this TypeScript via `--extension`; the + * server writes it to a cache so packaged builds need no sibling .ts file. * * Do not import t3code modules from the string body. The Pi process resolves * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. @@ -234,9 +234,18 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // Pi deliberately leaves permission policy to extensions. T3's injected // bridge uses Pi's public blocking tool hook so the shared runtime modes // keep their normal meaning without replacing or shadowing Pi's runtime. + // Only Pi's own search is known to be read-only. An extension that replaces + // it keeps the name, and cannot discover deferred tools either. + const hasBuiltinToolSearch = () => + typeof pi.getAllTools === "function" && + pi.getAllTools().some((tool) => tool.name === "tool_search" && tool.sourceInfo?.path === "builtin:tool-search"); + pi.on("tool_call", async (event, ctx) => { const mode = runtimeMode(); - if (mode === "full-access" || READ_ONLY_TOOLS.has(event.toolName)) return; + if (mode === "full-access") return; + if (event.toolName === "tool_search" ? hasBuiltinToolSearch() : READ_ONLY_TOOLS.has(event.toolName)) { + return; + } if (mode === "auto-accept-edits" && FILE_CHANGE_TOOLS.has(event.toolName)) { return; } @@ -261,28 +270,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) { return; } + // Tool exposure arrived with registerMcpServer in Pi 0.99. Keep the HTTP + // bridge as the credential owner: mcp.json overrides native registrations. + const supportsExposure = "registerMcpServer" in pi && typeof pi.registerMcpServer === "function"; + const directTools = new Set(["orchestrator_capabilities", "delegate_task", "task_status"]); + let deferOptionalTools = supportsExposure; + let catalog: ReadonlyArray = []; + const client = createMcpClient(endpoint, token); let started: Promise | undefined; - const ensureStarted = () => { - if (started !== undefined) return started; - const attempt = (async () => { - const signal = AbortSignal.timeout(10_000); - await client.connect(signal); - const tools = await client.listTools(signal); - for (const tool of tools) { - const name = tool.name; - const registeredName = \`mcp__t3-code__\${name}\`; - const description = tool.description ?? name; + const registerTools = () => { + // Preserve public names for saved loadouts and tool selectors. Hidden + // canonical names reserve ownership against Pi's configured MCP servers. + const prefixes = supportsExposure ? ["mcp__t3-code__", "mcp__t3_code__"] : ["mcp__t3-code__"]; + for (const tool of catalog) { + const name = tool.name; + for (const prefix of prefixes) { + const exposure = prefix === "mcp__t3_code__" ? "hidden" : + deferOptionalTools && !directTools.has(name) ? "deferred" : "direct"; pi.registerTool({ - name: registeredName, + name: \`\${prefix}\${name}\`, label: name, - description, - promptSnippet: description.split("\\n")[0] ?? name, - promptGuidelines: [ - \`Use \${registeredName} from the t3-code MCP server when the user asks for T3 orchestration that this tool covers.\`, - ], + description: tool.description ?? name, parameters: jsonSchemaToTypebox(tool.inputSchema), + ...(supportsExposure ? { exposure } : {}), async execute(_toolCallId, params, signal) { const result = await client.callTool( name, @@ -298,6 +310,16 @@ export default async function t3McpExtension(pi: ExtensionAPI) { }, }); } + } + }; + + const ensureStarted = () => { + if (started !== undefined) return started; + const attempt = (async () => { + const signal = AbortSignal.timeout(10_000); + await client.connect(signal); + catalog = await client.listTools(signal); + registerTools(); })(); started = attempt; void attempt.catch(() => { @@ -306,13 +328,28 @@ export default async function t3McpExtension(pi: ExtensionAPI) { return attempt; }; - // Await here so tools exist before session_start and the first prompt. - // session_start is a retry if the process later reloads the extension. - // Best effort during extension load. A failed first connection is retried - // below on session_start instead of pinning this process to the failure. + // CLI extensions load before builtins, and Pi keeps the first registration + // of a tool name. Register now so the bridge owns the T3 namespace even when + // mcp.json configures it; retry a failed connection at session_start. await ensureStarted().catch(() => undefined); + const reconcileDiscovery = () => { + if (!supportsExposure) return; + // A disabled or replaced search builtin cannot discover deferred tools. + const hasToolSearch = hasBuiltinToolSearch(); + const exposureChanged = deferOptionalTools !== hasToolSearch; + deferOptionalTools = hasToolSearch; + if (exposureChanged) registerTools(); + if (hasToolSearch) { + const active = pi.getActiveTools(); + if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]); + } + }; + + // Tree navigation restores its saved loadout after session_start. + pi.on("session_tree", reconcileDiscovery); pi.on("session_start", async (_event, ctx) => { + reconcileDiscovery(); try { await ensureStarted(); } catch (error) { diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts index f8f849246bfc..b462b98a612a 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts @@ -162,6 +162,7 @@ describe("pi T3 MCP injection", () => { assert.include(mcpSource, '"mcp-protocol-version"'); assert.include(mcpSource, '"tools/call"'); assert.include(mcpSource, "mcp__t3-code__"); + assert.include(mcpSource, "mcp__t3_code__"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 5fc670a99c6d..14437ee7f690 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -145,8 +145,13 @@ provider-specific extensions; those remain in flavors such as Grok. ### Pi V2 -Pi core has no MCP client. When a provider session credential exists, the -adapter writes a T3-owned extension into the server cache and spawns +T3 keeps a provider-session HTTP bridge even when Pi supports native MCP. +`mcp.json` entries override native registrations, and native MCP's default +60-second request timeout can interrupt long-running T3 tools. The bridge owns +the injected endpoint and credential and forwards Pi's cancellation signal. + +When a provider session credential exists, the adapter writes a T3-owned +extension into the server cache and spawns `pi --mode rpc --extension /pi-t3-mcp-extension.ts` with: ```text @@ -154,9 +159,13 @@ T3_MCP_URL=http://127.0.0.1:/mcp T3_MCP_BEARER_TOKEN= ``` -The extension connects to that HTTP endpoint, lists tools, and registers each -one with `pi.registerTool` under a `mcp__t3-code__` namespace -(`mcp__t3-code__delegate_task`, `mcp__t3-code__t3_thread_launch`, and the rest). +The extension preserves public names under `mcp__t3-code__` for saved loadouts +and tool selectors. Modern Pi also receives hidden `mcp__t3_code__` aliases, +which reserve the normalized namespace against configured MCP servers without +adding declarations or search results. On Pi 0.99+, +`orchestrator_capabilities`, `delegate_task`, and `task_status` remain directly +available; optional tools are discovered through Pi's builtin `tool_search`. +On older Pi or without builtin search, all tools remain directly available. The bridge calls the original MCP tool name over HTTP. Follow-up requests send `mcp-protocol-version: 2025-06-18`; Effect's MCP transport returns 400 without it. The first turn of a session also receives the shared T3 From eb1cf7bd0efba7364d53907036f5b7eefb379c39 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:39:47 -0700 Subject: [PATCH 009/377] fix(web): stack merge dialog closes as soon as you confirm (#17116) --- .../pullRequest/PullRequestStackMenu.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx index 61eeed0f0f32..ff8e886d14af 100644 --- a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -87,6 +87,11 @@ export function PullRequestStackMenu({ layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], ); setPending(true); + setConfirmation(null); + const toastId = toastManager.add({ + type: "loading", + title: action === "merge" ? "Merging stack..." : "Rebasing stack...", + }); const result = await runAction({ environmentId, input: { @@ -99,16 +104,15 @@ export function PullRequestStackMenu({ }, }); setPending(false); - setConfirmation(null); onActed(); if (result._tag === "Failure") { - toastManager.add({ + toastManager.update(toastId, { type: "error", - title: "Stack operation did not complete", + title: action === "merge" ? "Could not merge the stack" : "Could not rebase the stack", description: String(squashAtomCommandFailure(result)), }); } else { - toastManager.add({ + toastManager.update(toastId, { type: "success", title: action === "merge" ? "Stack merge request completed" : "Stack rebased", description: @@ -216,10 +220,10 @@ export function PullRequestStackMenu({ { - if (!value && !pending) setConfirmation(null); + if (!value) setConfirmation(null); }} > - + {confirmation === "merge" @@ -245,11 +249,11 @@ export function PullRequestStackMenu({ - - From e8f5850b824572bab76bf810696f875eb648eff9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:41:08 +0200 Subject: [PATCH 010/377] fix(server): Pi editor dialogs prefill the answer composer (#17206) --- .../features/threads/QuestionAttachments.tsx | 2 + apps/mobile/src/lib/threadActivity.test.ts | 23 ++ apps/mobile/src/lib/threadActivity.ts | 16 +- .../src/state/use-selected-thread-requests.ts | 14 ++ apps/server/src/mcp/toolkits/thread/tools.ts | 1 + .../Adapters/PiAdapterV2.test.ts | 59 +++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 26 +- apps/web/src/components/ChatView.tsx | 15 ++ .../src/components/ComposerPromptEditor.tsx | 4 +- .../components/ComposerPromptEditorTiptap.tsx | 223 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 88 +++++-- apps/web/src/composer-logic.test.ts | 30 +++ apps/web/src/composer-logic.ts | 32 ++- apps/web/src/composer-rich-text-doc.test.ts | 48 ++++ apps/web/src/composer-rich-text-doc.ts | 11 +- apps/web/src/pendingUserInput.test.ts | 28 +++ apps/web/src/pendingUserInput.ts | 22 +- .../src/state/threadRequests.test.ts | 35 ++- .../src/state/threadRequests.ts | 21 ++ packages/contracts/src/orchestrationV2.ts | 2 + packages/contracts/src/providerRuntime.ts | 2 + .../contracts/src/userInputQuestion.test.ts | 23 ++ 22 files changed, 570 insertions(+), 155 deletions(-) create mode 100644 packages/contracts/src/userInputQuestion.test.ts diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 0214b2440fd8..803de4e3a9ae 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -189,6 +189,8 @@ export function QuestionAttachments(props: { props.onInputFocusChange?.(true)} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d88e840ba40f..e5ddde4993b6 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2011,6 +2011,29 @@ const multiSelectQuestion = { } as const; describe("pending user input answers", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect( + buildPendingUserInputAnswers([question], { + runtime: { customAnswer: question.initialAnswer }, + }), + ).toEqual({ runtime: question.initialAnswer }); + expect( + buildPendingUserInputAnswers([question], { + runtime: { customAnswer: " Edited message\n\n" }, + }), + ).toEqual({ runtime: " Edited message\n\n" }); + expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ + runtime: "", + }); + expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ + runtime: " \n", + }); + expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( + { customAnswer: "" }, + ); + }); + it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 2dc513e40f02..d06af828ee11 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -399,8 +399,12 @@ function resolvePendingUserInputAnswer( ): string | ReadonlyArray | null { if (draft?.attachmentsBlocked) return null; const customAnswer = - question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer) { + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { return customAnswer; } @@ -408,12 +412,12 @@ function resolvePendingUserInputAnswer( if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); } return ( selectedOptionValues[0] ?? + customAnswer ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) ); } @@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer( } const selectedOptionValues = - customAnswer.trim().length > 0 + question.initialAnswer !== undefined || customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); return { diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 0c32c6a37c14..eeb5a017f067 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c import { type PendingThreadRequests, type ThreadUserInputQuestion, + seedUserInputDraftAnswers, } from "@t3tools/client-runtime/state/thread-requests"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -104,6 +105,19 @@ export function useSelectedThreadRequests() { const activePendingApproval = activePendingApprovals[0] ?? null; const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs; const activePendingUserInput = activePendingUserInputs[0] ?? null; + useEffect(() => { + if (!activePendingUserInput || !selectedThreadShell) return; + const requestKey = scopedRequestKey( + selectedThreadShell.environmentId, + activePendingUserInput.requestId, + ); + const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); + const drafts = existing[requestKey] ?? {}; + const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); + if (seeded !== drafts) { + appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded }); + } + }, [activePendingUserInput, selectedThreadShell]); const questionServerConfigs = useServerConfigs(); const attachmentDrafts = useAtomValue(composerDraftsAtom); const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 24cd6af444ba..b5fb7d6be3a6 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -141,6 +141,7 @@ const question = Schema.Struct({ ), multiSelect: Schema.optional(Schema.Boolean), allowCustomAnswer: Schema.optional(Schema.Boolean), + initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); const pendingRequest = Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 6e385f1a1489..4db9305cd2e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -2400,6 +2400,65 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("keeps the complete editor prefill editable and returns exact edited text", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `; + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-editor", + method: "editor", + title: "Commit message", + prefill, + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") + return; + assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); + assert.deepEqual(event.turnItem.questions[0]?.options, []); + assert.equal(event.turnItem.questions[0]?.question, "Commit message"); + const edited = " fix(server): edited commit message\n\n"; + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: { "ui-editor": edited }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["value"], edited); + assert.isUndefined(response["cancelled"]); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-editor-cleared", + method: "editor", + title: "Commit message", + prefill, + }); + const cleared = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "user_input_request" && + event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"), + ); + if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request") + return; + yield* runtime.respondToRuntimeRequest({ + requestId: cleared.turnItem.requestId, + answers: { "ui-editor-cleared": "" }, + }); + const emptyResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(emptyResponse["value"], ""); + assert.isUndefined(emptyResponse["cancelled"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("raises bridge edit confirmations as file-change approvals", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index a319b4b2f0b7..b05454aefc12 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -3160,25 +3160,21 @@ function piQuestion( ? event["options"] .filter((option): option is string => typeof option === "string") .map((option) => ({ label: option || "Empty value", description: option, value: option })) - : [ - { - label: "Submit empty value", - description: "Send an empty string to the extension.", - value: "", - }, - ]; - // The user-input contract has no prefill field, so an editor dialog's - // prefill is surfaced inside the question text; without it the user would - // edit blind against content they cannot see. - const prefill = method === "editor" ? recordString(event, "prefill") : undefined; + : method === "editor" + ? [] + : [ + { + label: "Submit empty value", + description: "Send an empty string to the extension.", + value: "", + }, + ]; const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; return { id: questionId, header: title, - question: - prefill === undefined || prefill.length === 0 - ? question - : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, + question, + ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), options, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b70ffb17576..d6baf94b5ff4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -217,6 +217,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; +import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests"; import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { @@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activePendingUserInput?.requestId, ]); + const activePendingAnswerDrafts = + pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + if ( + activePendingUserInput && + seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== + activePendingAnswerDrafts + ) { + setPendingUserInputAnswersByRequestId((existing) => { + const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); + return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; + }); + } const pendingQuestionDraftKeys = useMemo( () => activeThreadId @@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) { [questionId]: setPendingUserInputCustomAnswer( existing[activePendingRequestKey]?.[questionId], value, + question, ), }, })); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 7e699b6e0304..cb4810e6f17e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -9,8 +9,8 @@ export type { /** * The composer editor. Tiptap in both modes: the `richTextEnabled` setting - * toggles Markdown styling, never the engine. Plain mode renders every - * marker as a literal character and serializes byte-identically. + * toggles Markdown styling, never the engine. Plain mode keeps Markdown + * markers literal; `literalText` also preserves context-token sources as text. */ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { return ; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index babdfc4a1f41..22002431097f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -140,6 +140,8 @@ export interface ComposerPromptEditorProps { * literal character. */ richTextEnabled?: boolean; + /** Preserve editor answers verbatim, without Markdown or context chips. */ + literalText?: boolean; /** Draft records behind the prompt's context references, keyed by context id. */ contextRecords: ComposerDraftContextRecords; /** Structured clipboard payload for the given referenced ids, or null to skip. */ @@ -773,11 +775,13 @@ const ComposerMarkersExtension = Extension.create({ type TiptapEditor = NonNullable>; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { - // Extensions are creation-time: flipping the setting remounts the editor. - // Both halves initialize from the controlled Markdown value, so the draft - // survives the flip. + // Extensions are creation-time, so changing text mode remounts the editor. + // Every mode initializes from the controlled value, preserving the draft. return ( - + ); } @@ -806,6 +810,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { value, cursor, richTextEnabled, + literalText = false, contextRecords, buildContextClipboardFragment, importContextFragment, @@ -830,7 +835,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } = props; // The setting toggles styling, not the engine: both modes are Tiptap. // Plain mode disables the mark extensions, so markers stay literal text. - const richText = richTextEnabled ?? false; + const richText = !literalText && (richTextEnabled ?? false); + const clampEditorCursor = useCallback( + (text: string, position: number) => + literalText + ? Math.max( + 0, + Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length), + ) + : clampCollapsedComposerCursor(text, position), + [literalText], + ); + const expandEditorCursor = useCallback( + (text: string, position: number) => + literalText ? position : expandCollapsedComposerCursor(text, position), + [literalText], + ); const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); @@ -878,13 +898,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; }, []); - const initialCursor = clampCollapsedComposerCursor(value, cursor); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); + const initialCursor = clampEditorCursor(value, cursor); + const initialExpandedCursor = expandEditorCursor(value, initialCursor); const snapshotRef = useRef({ value, cursor: initialCursor, expandedCursor: initialExpandedCursor, - contextIds: collectInlineContextIds(value), + contextIds: literalText ? [] : collectInlineContextIds(value), }); const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); const isApplyingControlledUpdateRef = useRef(false); @@ -910,62 +930,68 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { [onCitationSubmitAndSend, openCitation], ); - const handleEditorChange = useCallback((updated: TiptapEditor) => { - const map = serializeEditorDoc(updated.state.doc); - const { from, to } = updated.state.selection; - const fromFlat = pmToFlat(map, from); - const toFlat = pmToFlat(map, to); - const nextValue = map.value; - const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); - const nextExpandedCursor = Math.max( - 0, - Math.min(map.value.length, flatToMarkdown(map, fromFlat)), - ); - const nextSelectionRange = { - start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), - end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), - }; - const previousSelectionRange = selectionRangeRef.current; - selectionRangeRef.current = nextSelectionRange; - setIsEmpty(nextValue.length === 0); - const previousSnapshot = snapshotRef.current; - const snapshotChanged = !( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.contextIds.length === map.contextIds.length && - previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) - ); - if (isApplyingControlledUpdateRef.current) return; - if (!snapshotChanged) { - if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); + const handleEditorChange = useCallback( + (updated: TiptapEditor) => { + const map = serializeEditorDoc(updated.state.doc); + // Literal answers hold no chips, so reference-shaped text is not a context. + const contextIds = literalText ? [] : map.contextIds; + const { from, to } = updated.state.selection; + const fromFlat = pmToFlat(map, from); + const toFlat = pmToFlat(map, to); + const nextValue = map.value; + const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)); + const nextExpandedCursor = Math.max( + 0, + Math.min(map.value.length, flatToMarkdown(map, fromFlat)), + ); + const nextSelectionRange = { + start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), + }; + const previousSelectionRange = selectionRangeRef.current; + selectionRangeRef.current = nextSelectionRange; + setIsEmpty(nextValue.length === 0); + const previousSnapshot = snapshotRef.current; + const snapshotChanged = !( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.contextIds.length === contextIds.length && + previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) + ); + if (isApplyingControlledUpdateRef.current) return; + if (!snapshotChanged) { + if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { + onVisibleSelectionChangeRef.current?.(); + } + return; } - return; - } - // A selection-only update while a newer prompt waits to be applied (a chip - // was just inserted through the store) would report stale text and clobber - // the prompt. Let the controlled rewrite land instead. - if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds: map.contextIds, - }; - // A fence holds no chips, so nothing in it should summon the mention or - // command menu: `@` in code is a decorator, not a file. Suppressing the - // trigger here also keeps the store from inserting a link the block can - // only show as literal text, which the store would then count as a chip. - const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; - const suppressTrigger = - inCodeBlock || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); - }, []); + // A selection-only update while a newer prompt waits to be applied (a chip + // was just inserted through the store) would report stale text and clobber + // the prompt. Let the controlled rewrite land instead. + if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + contextIds, + }; + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + literalText || + inCodeBlock || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); + }, + [clampEditorCursor, literalText], + ); const editorAttributes = useMemo( () => ({ @@ -1122,7 +1148,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { description: shortDescription || found.description?.trim() || null, }; }, - { styling: richText }, + { styling: richText, literalText }, ), editable: !disabled, editorProps: { @@ -1319,7 +1345,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return handled; }, handleTextInput: (view, from, to, text) => { - if (text.length !== 1) return false; + if (literalText || text.length !== 1) return false; const closer = SURROUND_CLOSE[text]; if (!closer || from === to) return false; // Never wrap chips or other atoms, and never wrap styled text: the @@ -1365,11 +1391,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } const importFragment = importFragmentRef.current; - let text = importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; // Complete chips at paste boundaries just as autocomplete does. - const tokens = collectComposerPromptInlineTokens(`${text}\n`); + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); const lastToken = tokens.at(-1); if ( (lastToken?.type === "mention" || lastToken?.type === "skill") && @@ -1397,7 +1424,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested }, + { styling: richText, blocks: !nested, literalText }, (content) => { // Tagged on the same transaction insertContent builds, so the // paste is one undo step of its own. @@ -1467,9 +1494,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const fromFlat = pmToFlat(map, from); const next: typeof snapshot = { value: map.value, - cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), + cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)), expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), - contextIds: map.contextIds, + contextIds: literalText ? [] : map.contextIds, }; const toFlat = pmToFlat(map, to); selectionRangeRef.current = { @@ -1478,14 +1505,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; snapshotRef.current = next; return next; - }, [editor]); + }, [clampEditorCursor, editor, literalText]); // Controlled value/cursor from the store (history recall, chip insertion…). useLayoutEffect(() => { if (!editor) return; const initialSelection = !hasAppliedControlledSelectionRef.current; hasAppliedControlledSelectionRef.current = true; - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const normalizedCursor = clampEditorCursor(value, cursor); const previousSnapshot = snapshotRef.current; if ( !initialSelection && @@ -1494,7 +1521,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { return; } - const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); + const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor); snapshotRef.current = { value, cursor: normalizedCursor, @@ -1514,9 +1541,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pendingCitation = citationRequestRef.current?.value === value ? citationRequestRef.current : null; if (previousSnapshot.value !== value) { - editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { - emitUpdate: false, - }); + editor.commands.setContent( + buildDocJson(value, skillLabelFor, { styling: richText, literalText }), + { + emitUpdate: false, + }, + ); } const map = serializeEditorDoc(editor.state.doc); const flat = collapsedToFlat(map, normalizedCursor); @@ -1545,7 +1575,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, richText, skillLabelFor, value]); + }, [ + clampEditorCursor, + cursor, + editor, + expandEditorCursor, + literalText, + richText, + skillLabelFor, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { @@ -1559,7 +1598,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { // through the store). Reporting the editor's stale text now would // overwrite that prompt; the pending rewrite places the caret instead. if (snapshotRef.current.value !== latestValueRef.current) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); const map = serializeEditorDoc(editor.state.doc); const flat = collapsedToFlat(map, boundedCursor); editor.commands.setTextSelection(flatToPm(map, flat)); @@ -1568,7 +1607,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor), contextIds: snapshotRef.current.contextIds, }; selectionRangeRef.current = { @@ -1579,11 +1618,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, - false, + literalText, snapshotRef.current.contextIds, ); }, - [editor], + [clampEditorCursor, editor, expandEditorCursor, literalText], ); useImperativeHandle( @@ -1595,10 +1634,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { focusAt, focusAtEnd: () => { focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), + literalText + ? snapshotRef.current.value.length + : collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), ); }, readSelectionRange: () => { @@ -1660,7 +1701,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { : edgeRect.bottom - caretRect.bottom < threshold; }, }), - [editor, focusAt, readSnapshot], + [editor, focusAt, literalText, readSnapshot], ); const handleCopyCut = useCallback( @@ -1671,7 +1712,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const { from, to } = editor.state.selection; if (from === to) return; const text = serializeSelection(editor.state.doc, from, to); - const contextIds = Array.from(new Set(collectInlineContextIds(text))); + const contextIds = literalText ? [] : Array.from(new Set(collectInlineContextIds(text))); const fragment = contextIds.length > 0 ? build?.(contextIds) : null; event.preventDefault(); clipboardData.setData("text/plain", text); @@ -1691,7 +1732,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { .run(); } }, - [editor], + [editor, literalText], ); return ( @@ -1774,7 +1815,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean; blocks?: boolean }, + options: { styling: boolean; blocks?: boolean; literalText?: boolean }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 858f9a77850a..cfdb03d0f05c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1577,6 +1577,7 @@ export interface ChatComposerProps { customAnswer: string; activeQuestion: { id: string; + initialAnswer?: string | undefined; multiSelect?: boolean | undefined; allowCustomAnswer?: boolean | undefined; } | null; @@ -1807,6 +1808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editingQueuedAttachments, onRemoveEditingQueuedAttachment, } = props; + const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); // Opening a running thread resyncs for a few frames. Show the sync row, and @@ -2392,16 +2394,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ - const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), - ); + const [composerCursor, setComposerCursor] = useState(() => { + const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt; + return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer); + }); const { trigger: composerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, dismissTrigger: dismissComposerTrigger, resetTrigger: resetComposerTrigger, - } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); + } = useComposerTriggerState(() => + isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), + ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const composerSuggestionId = useId(); const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; @@ -3395,9 +3400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Sync refs back to parent // ------------------------------------------------------------------ useEffect(() => { + if (isLiteralPendingAnswer) return; promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + }, [isLiteralPendingAnswer, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -3498,13 +3504,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer); setComposerCursor(cursor); resetComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, + isLiteralPendingAnswer, activePendingUserInput?.requestId, prompt, promptRef, @@ -3519,11 +3526,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedSearchKey(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer); + setComposerCursor(cursor); + resetComposerTrigger(trigger); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); + }, [ + draftId, + activeThreadId, + isLiteralPendingAnswer, + promptRef, + resetComposerTrigger, + setIsComposerScrollCollapsed, + ]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -3866,8 +3881,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const nextCursor = collapseExpandedComposerCursor( next.text, options?.expandedCursorAfterReplace ?? next.cursor, + isLiteralPendingAnswer, + ); + const nextExpandedCursor = expandCollapsedComposerCursor( + next.text, + nextCursor, + isLiteralPendingAnswer, ); - const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); if (options?.citationComment) { composerEditorRef.current?.requestCitationComment({ previousValue: currentText, @@ -3885,13 +3905,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) next.text, nextCursor, nextExpandedCursor, - false, + isLiteralPendingAnswer, ); } else { setPrompt(next.text); } setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + setComposerTrigger( + isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), + ); if (options?.focusEditorAfterReplace !== false) { window.requestAnimationFrame(() => { // Type-to-focus routes only the first key through here; once the @@ -3907,6 +3929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, activePendingUserInput, + isLiteralPendingAnswer, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -3927,10 +3950,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - contextIds: collectInlineContextIds(promptRef.current), + expandedCursor: expandCollapsedComposerCursor( + promptRef.current, + composerCursor, + isLiteralPendingAnswer, + ), + contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), }; - }, [composerCursor, promptRef]); + }, [composerCursor, isLiteralPendingAnswer, promptRef]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -3939,11 +3966,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: resolveComposerTrigger( - detectComposerTrigger(snapshot.value, snapshot.expandedCursor), - ), + trigger: isLiteralPendingAnswer + ? null + : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), }; - }, [readComposerSnapshot, resolveComposerTrigger]); + }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( @@ -4434,7 +4461,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = composerMenuOpenRef.current || trigger !== null; + const menuIsActive = + !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; dismissComposerTrigger(trigger); @@ -4474,7 +4502,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } // Native task splitting preserves marks and chips on both sides of the caret. if (key === "Enter" && isTaskItem) return false; - if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { + if ( + !isLiteralPendingAnswer && + !event.isComposing && + (key === "Enter" || (key === "Tab" && !event.shiftKey)) + ) { const selection = composerEditorRef.current?.readSelectionRange(); const snapshot = readComposerSnapshot(); if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { @@ -5990,6 +6022,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) bypassAutoAttachment: boolean, selectionOverride?: { start: number; end: number }, ): boolean => { + // Editor answers keep pasted text in the answer, regardless of paste size. + if (isLiteralPendingAnswer) return false; const questionCanAttach = pendingUserInputs.length === 0 || (supportsQuestionAttachments && @@ -6436,11 +6470,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectTrigger?: boolean; }) => { const promptForState = options?.prompt ?? promptRef.current; - const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + const cursor = clampCollapsedComposerCursor( + promptForState, + options?.cursor ?? 0, + isLiteralPendingAnswer, + ); setComposerHighlightedItemId(null); setComposerCursor(cursor); resetComposerTrigger( - options?.detectTrigger + options?.detectTrigger && !isLiteralPendingAnswer ? detectComposerTrigger( promptForState, expandCollapsedComposerCursor(promptForState, cursor), @@ -6449,7 +6487,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }, addTerminalContext: (selection: TerminalContextSelection) => { - if (!activeThread || isChoiceOnlyPendingQuestion) return; + if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return; const snapshot = readComposerSnapshot(); const context = { id: randomUUID(), @@ -6543,6 +6581,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isComposerApprovalState, isChoiceOnlyPendingQuestion, + isLiteralPendingAnswer, pendingUserInputs.length, projectSelectionRequired, applyPromptReplacement, @@ -7400,6 +7439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } editorRef={composerEditorRef} richTextEnabled={settings.composerRichTextEnabled} + literalText={isLiteralPendingAnswer} value={ isComposerApprovalState ? "" diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index f931f9ebc378..df3e54b4ddfc 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -817,3 +817,33 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); }); + +describe("literal editor answer cursor state", () => { + const prefill = "- [X] done $my-skill "; + + it("keeps character positions after token-like text for inserted lines and replacements", () => { + for (const replacement of ["\n", "\t", "edited\n "]) { + const next = replaceTextRange(prefill, prefill.length, prefill.length, replacement); + const cursor = collapseExpandedComposerCursor(next.text, next.cursor, true); + expect(next.text).toBe(prefill + replacement); + expect(cursor).toBe(next.text.length); + expect(expandCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); + expect(clampCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); + } + const replaced = replaceTextRange(prefill, 11, 20, "$another-skill"); + const cursor = collapseExpandedComposerCursor(replaced.text, replaced.cursor, true); + expect(replaced.text).toBe("- [X] done $another-skill "); + expect(cursor).toBe(25); + expect(expandCollapsedComposerCursor(replaced.text, cursor, true)).toBe(25); + }); + + it("does not open a skill or path menu when restoring an editor answer", () => { + for (const value of ["- [X] done $my-skill", "Use @README", ""]) { + expect(composerStateAtPromptEnd(value, true)).toEqual({ + cursor: value.length, + trigger: null, + }); + } + expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 09c5e714aeb3..c26996f494e5 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -89,7 +89,12 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { +export function expandCollapsedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); const collapsedCursor = clampCursor(text, cursorInput); const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { @@ -155,14 +160,24 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { +export function clampCollapsedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); return clampCollapsedComposerCursorForSegments( splitPromptIntoComposerSegments(text), cursorInput, ); } -export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { +export function collapseExpandedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); const expandedCursor = clampCursor(text, cursorInput); const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { @@ -292,14 +307,19 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } /** Caret and trigger after replacing composer text and continuing at the end. */ -export function composerStateAtPromptEnd(text: string): { +export function composerStateAtPromptEnd( + text: string, + literalText = false, +): { cursor: number; trigger: ComposerTrigger | null; } { - const cursor = collapseExpandedComposerCursor(text, text.length); + const cursor = collapseExpandedComposerCursor(text, text.length, literalText); return { cursor, - trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + trigger: literalText + ? null + : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), }; } diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 5b26b74cacfd..1693cdbae9ac 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -3,6 +3,7 @@ import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { splitBlockKeepMarks } from "@tiptap/pm/commands"; import { describe, expect, it } from "vite-plus/test"; import { collapseExpandedComposerCursor } from "./composer-logic"; @@ -135,6 +136,53 @@ function roundTripPlain(value: string) { return serializeEditorDoc(doc); } +describe("literal editor answers", () => { + const makeDoc = (value: string) => + ProseMirrorNode.fromJSON( + plainSchema, + buildDocJson(value, (name) => ({ label: name, description: null }), { + literalText: true, + }), + ); + + it.each([ + "", + "\n\n", + " - [X] done\n\n**keep** €my-skill @README.md \n", + "line one\r\nline two\r\n", + `- [X] done\n${" detail €my-skill @README.md\n".repeat(100)}`, + ])("preserves the complete answer through selection and serialization", (value) => { + const doc = makeDoc(value); + doc.check(); + const serialized = serializeEditorDoc(doc); + expect(serialized.value).toBe(value); + expect(serialized.contextIds).toEqual([]); + expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value); + for (let offset = 0; offset <= value.length; offset += 1) { + expect(flatToCollapsed(serialized, offset)).toBe(offset); + } + }); + + it("edits literal Markdown, inserts a newline, and preserves an explicitly cleared answer", () => { + const value = " - [X] done €my-skill @README.md "; + const doc = makeDoc(value); + let state = EditorState.create({ + doc, + selection: TextSelection.create(doc, doc.content.size - 1), + }); + state = state.apply(state.tr.insertText(" edited")); + expect( + splitBlockKeepMarks(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + state = state.apply(state.tr.insertText("second line ")); + expect(serializeEditorDoc(state.doc).value).toBe(`${value} edited\nsecond line `); + state = state.apply(state.tr.delete(1, state.doc.content.size - 1)); + expect(serializeEditorDoc(state.doc).value).toBe(""); + }); +}); + describe("composer rich text document model", () => { it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])( "canonicalizes %s skill aliases while preserving amounts", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index efdfb145dc1d..dc3525e1249a 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -537,8 +537,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean; blocks?: boolean }, + options?: { styling?: boolean; blocks?: boolean; literalText?: boolean }, ): Record[] { + // Editor answers are verbatim text, including Markdown and context-token sources. + if (options?.literalText) { + return value.split("\n").map((line) => ({ + type: "paragraph", + ...(line ? { content: [{ type: "text", text: line }] } : {}), + })); + } const styling = options?.styling ?? true; // Inline marks without block structure: text pasted into a list item or // quote, which has no line to write a nested block into. @@ -759,7 +766,7 @@ export function buildTiptapContent( export function buildDocJson( value: string, skillLabelFor: (name: string) => SkillMeta, - options?: { styling?: boolean }, + options?: { styling?: boolean; literalText?: boolean }, ) { const content = buildTiptapContent(value, skillLabelFor, options); // The empty line the rule extension keeps after a final rule. diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 24f1be6fa602..c536e0c377ae 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -54,6 +54,34 @@ const nativeChoiceQuestion = { } as const; describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + { selectedOptionValues: ["Orchestration-first"] }, + "", + question, + ), + ).toEqual({ customAnswer: "" }); + const questions = [question, { ...singleSelectQuestion, id: "follow-up" }]; + const drafts = { scope: { customAnswer: "" } }; + expect(findFirstUnansweredPendingUserInputQuestionIndex(questions, drafts)).toBe(1); + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("prefers a custom answer over selected options", () => { expect( resolvePendingUserInputAnswer(singleSelectQuestion, { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 538909d13d69..1f3e4d9ea048 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -45,8 +45,12 @@ export function resolvePendingUserInputAnswer( ): string | string[] | null { if (draft?.attachmentsBlocked) return null; const customAnswer = - question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer) { + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { return customAnswer; } @@ -56,13 +60,13 @@ export function resolvePendingUserInputAnswer( if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); } return ( selectedOptionValues[0] ?? + customAnswer ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) ); } @@ -70,9 +74,10 @@ export function resolvePendingUserInputAnswer( export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, + question?: Pick, ): PendingUserInputDraftAnswer { const selectedOptionValues = - customAnswer.trim().length > 0 + question?.initialAnswer !== undefined || customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionValues(draft?.selectedOptionValues); @@ -162,7 +167,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( draftAnswers: Record, ): number { const unansweredIndex = questions.findIndex( - (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), + (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null, ); return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; @@ -193,7 +198,8 @@ export function derivePendingUserInputProgress( selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), customAnswer, resolvedAnswer, - usingCustomAnswer: customAnswer.trim().length > 0, + usingCustomAnswer: + (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index 4245b3f88498..b238d9498bbe 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -7,10 +7,43 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; -import { createQuestionHistoryProjector, derivePendingThreadRequests } from "./threadRequests.ts"; +import { + createQuestionHistoryProjector, + derivePendingThreadRequests, + seedUserInputDraftAnswers, +} from "./threadRequests.ts"; const requestId = RuntimeRequestId.make("async-question"); const nodeId = NodeId.make("async-question-node"); + +describe("initial user input drafts", () => { + const questions = [ + { id: "editor", initialAnswer: " Proposed commit\n\n" }, + { id: "empty", initialAnswer: "" }, + { id: "ordinary" }, + { id: "choices", initialAnswer: "Ignore this", allowCustomAnswer: false }, + ]; + + it("seeds editable answers and preserves edits and selections on later snapshots", () => { + const seeded = seedUserInputDraftAnswers(questions, {}); + expect(seeded).toEqual({ + editor: { customAnswer: " Proposed commit\n\n" }, + empty: { customAnswer: "" }, + }); + const edits = { + ...seeded, + editor: { customAnswer: "" }, + empty: { selectedOptionValues: ["empty"], customAnswer: "" }, + }; + expect(seedUserInputDraftAnswers(questions, edits)).toEqual(edits); + expect( + seedUserInputDraftAnswers(questions, { editor: { customAnswer: "Edited commit" } }).editor, + ).toEqual({ customAnswer: "Edited commit" }); + expect(seedUserInputDraftAnswers(questions, {}).editor).toEqual({ + customAnswer: " Proposed commit\n\n", + }); + }); +}); const projection: OrchestrationV2ThreadProjection = { ...v2Projection, runtimeRequests: [ diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 9532c8f32282..031cbf50c48f 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -41,6 +41,27 @@ export interface PendingThreadRequests { readonly userInputs: ReadonlyArray; } +/** Seed each question once. Existing edits, cleared answers, and option selections win. */ +export function seedUserInputDraftAnswers( + questions: ReadonlyArray< + Pick + >, + drafts: Record, +): Record { + let seeded: Record = drafts; + for (const question of questions) { + if ( + question.initialAnswer === undefined || + question.allowCustomAnswer === false || + seeded[question.id] !== undefined + ) + continue; + if (seeded === drafts) seeded = { ...drafts }; + seeded[question.id] = { customAnswer: question.initialAnswer }; + } + return seeded; +} + /** Joins pending request entities to the request items that carry display data. */ export function derivePendingThreadRequests( projection: Pick, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b1c56457ef08..7a1e56b6d5e3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1161,6 +1161,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ ), multiSelect: Schema.optional(Schema.Boolean), allowCustomAnswer: Schema.optional(Schema.Boolean), + /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ + initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5645d230a47a..2f8de3dea24b 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -491,6 +491,8 @@ export const UserInputQuestion = Schema.Struct({ question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), allowCustomAnswer: Schema.optional(Schema.Boolean), + /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ + initialAnswer: Schema.optional(Schema.String), multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), diff --git a/packages/contracts/src/userInputQuestion.test.ts b/packages/contracts/src/userInputQuestion.test.ts new file mode 100644 index 000000000000..13c316d7cba4 --- /dev/null +++ b/packages/contracts/src/userInputQuestion.test.ts @@ -0,0 +1,23 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts"; +import { UserInputQuestion } from "./providerRuntime.ts"; + +const question = { id: "editor", header: "Message", question: "Edit message", options: [] }; + +describe("initial user input answers", () => { + it("keeps existing question payloads compatible and preserves exact initial text", () => { + for (const schema of [UserInputQuestion, OrchestrationV2UserInputQuestion]) { + const decode = Schema.decodeUnknownSync(schema); + expect(decode(question).initialAnswer).toBeUndefined(); + for (const initialAnswer of [ + "", + " fix(server): keep whitespace\n\n", + "detail\n".repeat(500), + ]) { + expect(decode({ ...question, initialAnswer }).initialAnswer).toBe(initialAnswer); + } + } + }); +}); From a0066d5cb7d18abd786c1c23bf835f898490fd37 Mon Sep 17 00:00:00 2001 From: jztmanyl Date: Thu, 8 Oct 2026 20:48:56 +0200 Subject: [PATCH 011/377] fix(desktop): generate valid User-Agent that follows RFC 9110 guidelines (#17264) --- .../src/app/DesktopAppIdentity.test.ts | 35 +++++++++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 7 +++- .../src/window/DesktopApplicationMenu.test.ts | 36 ++++++++++++++++++- .../src/window/DesktopApplicationMenu.ts | 11 +++--- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index dae68f46887e..cc0277d3ed10 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -217,7 +217,7 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); + assert.deepEqual(calls.setName, ["T3 Code Alpha"]); assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); @@ -237,6 +237,39 @@ describe("DesktopAppIdentity", () => { ); }); + it.effect.each([ + { stage: "Alpha", environment: {} }, + { + stage: "Nightly", + environment: { appVersion: "0.0.43-nightly.20260929.2428" }, + }, + { + stage: "Dev", + environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, + }, + ])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + const runtimeName = calls.setName[0]; + assert.isDefined(runtimeName); + assert.equal(runtimeName, `T3 Code ${stage}`); + // RFC 9110's token grammar, after Electron removes ASCII spaces. + assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`); + }), + { calls, environment }, + ); + }); + it.effect("sets the dock icon only when running unpackaged", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 69fce083d4fb..395c06141d64 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -91,7 +91,12 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - yield* electronApp.setName(environment.displayName); + // Electron removes spaces from this name to build the native User-Agent + // product token, but leaves parentheses intact. Keep the runtime name valid + // without rewriting preview sessions (which breaks Turnstile, #7110). + yield* electronApp.setName( + `${environment.branding.baseName} ${environment.branding.stageLabel}`, + ); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, applicationVersion: environment.appVersion, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f7290d017124..8de28324a58d 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -103,6 +103,7 @@ const layerElectronMenu = ( const configureMenu = ( selectedAction: Deferred.Deferred, applicationMenuTemplate: Deferred.Deferred, + environment: Partial = {}, ) => Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -116,7 +117,7 @@ const configureMenu = ( Layer.provideMerge(layerElectronDialog), Layer.provideMerge(layerElectronApp), Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( + DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe( Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), ), ), @@ -125,6 +126,39 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { + it.effect("keeps display branding in the macOS application menu", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate, { + platform: "darwin", + appVersion: "0.0.43-nightly.20260929.2428", + }); + + const template = yield* Deferred.await(applicationMenuTemplate); + const applicationMenu = template[0]; + assert.isDefined(applicationMenu); + assert.equal(applicationMenu.label, "T3 Code (Nightly)"); + if (!Array.isArray(applicationMenu.submenu)) { + throw new Error("Expected application menu submenu to be an array."); + } + assert.equal( + applicationMenu.submenu.find((item) => item.role === "about")?.label, + "About T3 Code (Nightly)", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "hide")?.label, + "Hide T3 Code (Nightly)", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "quit")?.label, + "Quit T3 Code (Nightly)", + ); + }), + ); + it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d3b8db895352..5aa59a2ae1b2 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -7,7 +7,6 @@ import * as Schema from "effect/Schema"; import type * as Electron from "electron"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; -import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -107,10 +106,8 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const appName = yield* electronApp.name; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -157,9 +154,9 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { template.push({ - label: appName, + label: environment.displayName, submenu: [ - { role: "about" }, + { role: "about", label: `About ${environment.displayName}` }, { label: "Check for Updates...", click: checkForUpdatesClick, @@ -173,11 +170,11 @@ export const make = Effect.gen(function* () { { type: "separator" }, { role: "services" }, { type: "separator" }, - { role: "hide" }, + { role: "hide", label: `Hide ${environment.displayName}` }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, - { role: "quit" }, + { role: "quit", label: `Quit ${environment.displayName}` }, ], }); } From 0b6ec43c54165a87941b7a070849be8979646040 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:56:27 +0200 Subject: [PATCH 012/377] fix(server): Pi discovers workspace skills and commands (#17190) --- .../src/provider/Drivers/PiDriver.test.ts | 219 ++++++++++++++++++ apps/server/src/provider/Drivers/PiDriver.ts | 21 ++ apps/server/src/provider/PiProvider.ts | 76 ++++-- 3 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/provider/Drivers/PiDriver.test.ts diff --git a/apps/server/src/provider/Drivers/PiDriver.test.ts b/apps/server/src/provider/Drivers/PiDriver.test.ts new file mode 100644 index 000000000000..7202f1784a8e --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.test.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId, type PiSettings } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ServerConfig from "../../config.ts"; +import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { PiDriver } from "./PiDriver.ts"; + +const layerTest = Layer.mergeAll( + ServerConfig.layerTest("/machine", { prefix: "t3-pi-driver-" }), + IdAllocator.layer, + ServerSettings.layerTest({ enableProviderUpdateChecks: false }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected HTTP")), + ), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const decodeRequest = Schema.decodeSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const encoder = new TextEncoder(); +const personalSkill = { + name: "skill:personal", + source: "skill", + sourceInfo: { scope: "user", path: "/home/.pi/agent/skills/personal/SKILL.md" }, +}; + +// Respond through the real stdio transport, with a distinct command catalog for each cwd. +const makePiSpawner = Effect.gen(function* () { + const pendingCommand = yield* Deferred.make(); + const launches: Array = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + assert.isTrue(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("Unexpected pipeline"); + launches.push(command); + const version = command.args.includes("--version"); + const stdout = yield* Queue.unbounded(); + const cwd = command.options.cwd; + return ChildProcessSpawner.makeHandle({ + // Outside the valid PID range, so transport cleanup cannot signal a real process. + pid: ChildProcessSpawner.ProcessId(999_999_999), + exitCode: version ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) : Effect.never, + isRunning: Effect.succeed(!version), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((chunk: Uint8Array) => { + const request = decodeRequest(new TextDecoder().decode(chunk).trim()); + if (cwd === "/pending" && request.type === "get_commands") { + return Deferred.succeed(pendingCommand, undefined).pipe(Effect.asVoid); + } + const failed = cwd === "/failed" && request.type === "get_commands"; + const data = + request.type === "get_commands" + ? { + commands: [ + personalSkill, + ...(cwd === "/machine" + ? [] + : [ + { + name: `skill:${cwd?.slice(1)}`, + source: "skill", + sourceInfo: { + scope: "project", + path: `${cwd}/.agents/skills/SKILL.md`, + }, + }, + { name: `prompt-${cwd?.slice(1)}`, source: "prompt" }, + ]), + ], + } + : request.type === "get_available_models" + ? { models: [{ provider: "test", id: "model" }] } + : {}; + return Queue.offer( + stdout, + encoder.encode( + `${JSON.stringify({ type: "response", id: request.id, success: !failed, data, ...(failed ? { error: "commands unavailable" } : {}) })}\n`, + ), + ).pipe(Effect.asVoid); + }), + stdout: version ? Stream.succeed(encoder.encode("pi 1.0.2\n")) : Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, launches, pendingCommand }; +}); + +const create = (config: Partial = {}, enabled = true) => + PiDriver.create({ + instanceId: ProviderInstanceId.make("pi-workspace-test"), + displayName: "My Pi", + accentColor: "#abcdef", + environment: [{ name: "PI_CODING_AGENT_DIR", value: "/isolated-pi", sensitive: false }], + enabled, + config: { ...PiDriver.defaultConfig(), binaryPath: "custom-pi", ...config }, + }); + +it.layer(layerTest)("PiDriver workspace discovery", (it) => { + it.effect("keeps each workspace's skills and commands separate from the machine catalog", () => + Effect.gen(function* () { + const { spawner, launches } = yield* makePiSpawner; + const instance = yield* create({ launchArgs: '--approve --skill "extra skill"' }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* instance.snapshot.refresh; + assert.isDefined(instance.snapshotForCwd); + const [first, second] = yield* Effect.all( + [instance.snapshotForCwd!("/first"), instance.snapshotForCwd!("/second")], + { concurrency: "unbounded" }, + ); + assert.deepEqual( + first.skills.map((skill) => skill.name), + ["personal", "first"], + ); + assert.deepEqual( + second.skills.map((skill) => skill.name), + ["personal", "second"], + ); + assert.deepEqual( + first.slashCommands.map((command) => command.name), + ["compact", "prompt-first"], + ); + const machine = yield* instance.snapshot.getSnapshot; + assert.deepEqual( + machine.skills.map((skill) => skill.name), + ["personal"], + ); + assert.deepEqual( + machine.slashCommands.map((command) => command.name), + ["compact"], + ); + assert.equal(first.instanceId, instance.instanceId); + assert.equal(first.displayName, "My Pi"); + assert.equal(first.accentColor, "#abcdef"); + assert.deepEqual(first.models, machine.models); + const workspaceLaunch = launches.find((launch) => launch.options.cwd === "/first"); + assert.isDefined(workspaceLaunch); + assert.equal(workspaceLaunch!.command, "custom-pi"); + assert.includeMembers( + [...workspaceLaunch!.args], + ["--approve", "--skill", "extra skill", "--no-session"], + ); + assert.equal(workspaceLaunch!.options.env?.PI_CODING_AGENT_DIR, "/isolated-pi"); + }).pipe(Effect.scoped), + ); + + it.effect("does not run a disabled provider's workspace probe", () => + Effect.gen(function* () { + const instance = yield* create({}, false).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("Disabled Pi must not spawn")), + ), + ); + assert.isDefined(instance.snapshotForCwd); + const workspace = yield* instance.snapshotForCwd!("/first"); + assert.isFalse(workspace.enabled); + assert.deepEqual(workspace.skills, []); + }).pipe(Effect.scoped), + ); + + it.effect( + "fails command discovery instead of returning an empty successful workspace catalog", + () => + Effect.gen(function* () { + const { spawner } = yield* makePiSpawner; + const instance = yield* create().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* instance.snapshot.refresh; + assert.isDefined(instance.snapshotForCwd); + const error = yield* Effect.flip(instance.snapshotForCwd!("/failed")); + assert.equal(error._tag, "ProviderDriverError"); + assert.equal(error.instanceId, instance.instanceId); + assert.deepEqual( + (yield* instance.snapshot.getSnapshot).skills.map((skill) => skill.name), + ["personal"], + ); + }).pipe(Effect.scoped), + ); + + it.effect("times out workspace discovery that needs interactive input", () => + Effect.gen(function* () { + const { spawner, pendingCommand } = yield* makePiSpawner; + const instance = yield* create().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const probe = yield* instance.snapshotForCwd!("/pending").pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(pendingCommand); + yield* TestClock.adjust("15 seconds"); + const error = yield* Fiber.join(probe); + assert.equal(error._tag, "ProviderDriverError"); + assert.include(error.detail, "workspace commands"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 07c33753874a..9a7ec6320b7d 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -26,6 +26,7 @@ import { ProviderDriverError } from "../Errors.ts"; import { buildInitialPiProviderSnapshot, checkPiProviderStatus, + discoverPiCommandsForCwd, enrichPiSnapshot, } from "../PiProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -188,6 +189,26 @@ export const PiDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd: (workspaceCwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverPiCommandsForCwd(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to discover Pi workspace commands.", + cause, + }), + ), + ), + ]).pipe( + Effect.map(([machineSnapshot, commands]) => ({ ...machineSnapshot, ...commands })), + ), orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/PiProvider.ts b/apps/server/src/provider/PiProvider.ts index ec785b85a33b..2aaf9ab72e8d 100644 --- a/apps/server/src/provider/PiProvider.ts +++ b/apps/server/src/provider/PiProvider.ts @@ -32,6 +32,8 @@ import { } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; import { makePiRpcConnection, + PiRpcError, + PiRpcTimeoutError, piRecordField as recordField, piRecordString as recordString, } from "../orchestration-v2/Adapters/PiRpc.ts"; @@ -126,6 +128,33 @@ function parseDiscoveredModels( return parsed; } +const makePiDiscoveryConnection = Effect.fnUntraced(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + cwd?: string, +) { + const launch = buildPiRpcLaunch({ + launchArgs, + environment, + mcpSession: undefined, + extensionPath: undefined, + ephemeral: true, + }); + const connection = yield* makePiRpcConnection({ + command: piSettings.binaryPath || "pi", + args: launch.args, + cwd, + env: launch.env, + }); + yield* Stream.fromQueue(connection.events).pipe( + Stream.runDrain, + Effect.ignore, + Effect.forkScoped, + ); + return connection; +}); + const discoverPiViaRpc = ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, @@ -133,24 +162,7 @@ const discoverPiViaRpc = ( cwd?: string, ) => Effect.gen(function* () { - const launch = buildPiRpcLaunch({ - launchArgs, - environment, - mcpSession: undefined, - extensionPath: undefined, - ephemeral: true, - }); - const connection = yield* makePiRpcConnection({ - command: piSettings.binaryPath || "pi", - args: launch.args, - cwd, - env: launch.env, - }); - yield* Stream.fromQueue(connection.events).pipe( - Stream.runDrain, - Effect.ignore, - Effect.forkScoped, - ); + const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); const stateData = yield* connection.request({ type: "get_state" }); const modelsData = yield* connection.request({ type: "get_available_models" }); const commandsData = yield* connection @@ -169,6 +181,34 @@ const discoverPiViaRpc = ( } satisfies PiDiscovery; }).pipe(Effect.scoped); +/** Probe the full command catalog Pi exposes in a workspace without changing machine health. */ +export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( + function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { + const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs); + if (!launchArgs.ok) { + return yield* new PiRpcError({ operation: "launch", detail: launchArgs.message }); + } + const connection = yield* makePiDiscoveryConnection( + piSettings, + environment, + launchArgs.args, + cwd, + ); + // A failed read must not replace a previously usable workspace catalog with an empty one. + const commandsData = yield* connection.request({ type: "get_commands" }); + const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; + }, + Effect.scoped, + Effect.timeoutOrElse({ + duration: PI_RPC_DISCOVERY_TIMEOUT_MS, + orElse: () => + Effect.fail( + new PiRpcTimeoutError({ operation: "discovery", timeoutMs: PI_RPC_DISCOVERY_TIMEOUT_MS }), + ), + }), +); + const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => Effect.gen(function* () { const command = piSettings.binaryPath || "pi"; From 805967a878e6804d58c151f29d2a3d0a06828fd1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 12:02:21 -0700 Subject: [PATCH 013/377] fix(mobile): preserve navigation after native swipe back (#17268) --- .../src/native/createV5StackNavigator.ios.tsx | 63 ++++++++++++++++--- .../native/workspace-stack-projection.test.ts | 58 +++++++++++++++++ .../src/native/workspace-stack-projection.ts | 27 ++++++++ 3 files changed, 138 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/native/createV5StackNavigator.ios.tsx b/apps/mobile/src/native/createV5StackNavigator.ios.tsx index 413bd8712704..7dcbe73ab769 100644 --- a/apps/mobile/src/native/createV5StackNavigator.ios.tsx +++ b/apps/mobile/src/native/createV5StackNavigator.ios.tsx @@ -20,12 +20,13 @@ import { type NativeStackNavigatorProps, type NativeStackTypeBag, } from "@react-navigation/native-stack"; -import { useCallback, useState, type ComponentProps } from "react"; +import { useCallback, useRef, useState, type ComponentProps } from "react"; import { StyleSheet, View } from "react-native"; import { FormSheet, Stack } from "react-native-screens"; import { V5StackHeader } from "./V5StackHeader.ios"; import { NativeColumnContent } from "./NativeColumnContent.ios"; import { + nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, partitionStackPresentations, @@ -52,8 +53,10 @@ export function modalEnvelopeOptions(options: NativeStackNavigationOptions) { /** Keep outgoing screens until UIKit completes its pop, as required by v5. */ export function V5CardStackView(props: V5StackViewProps) { const { preventedRoutes } = usePreventRemoveContext(); + const nativePopSource = useRef(null); const [screens, setScreens] = useState({ completedNativeDismissals: new Set(), + nativeCompletingPops: new Set(), routes: props.state.routes, descriptors: props.descriptors, observedRoutes: props.state.routes, @@ -71,6 +74,9 @@ export function V5CardStackView(props: V5StackViewProps) { const active = new Set(props.state.routes.map((route) => route.key)); const retainedKeys = new Set(routes.map((route) => route.key)); setScreens({ + nativeCompletingPops: new Set( + [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)), + ), completedNativeDismissals: new Set( [...screens.completedNativeDismissals].filter((key) => active.has(key)), ), @@ -92,9 +98,11 @@ export function V5CardStackView(props: V5StackViewProps) { const attached = state.routes.some((route) => route.key === key); setScreens((current) => { const completedNativeDismissals = new Set(current.completedNativeDismissals); + const nativeCompletingPops = new Set(current.nativeCompletingPops); + nativeCompletingPops.delete(key); if (attached) { completedNativeDismissals.add(key); - return { ...current, completedNativeDismissals }; + return { ...current, completedNativeDismissals, nativeCompletingPops }; } // A delayed callback needs immediate cleanup: the router already // removed the route, so no further router update will follow. @@ -104,11 +112,13 @@ export function V5CardStackView(props: V5StackViewProps) { return { ...current, completedNativeDismissals, + nativeCompletingPops, routes: current.routes.filter((route) => route.key !== key), descriptors, }; }); - const action = nativeWorkspacePopAction(state, key); + if (nativePopSource.current === key) nativePopSource.current = null; + const action = nativeStackPopAction(state, key); if (action) props.navigation.dispatch(action); }, [props.navigation, setScreens], @@ -140,7 +150,9 @@ export function V5CardStackView(props: V5StackViewProps) { + onDidAppear={() => { + const source = nativePopSource.current; + if (source === route.key) { + // A cancelled swipe restores its source without changing router history. + nativePopSource.current = null; + } else if (source) { + const state = props.navigation.getState(); + const action = nativeStackPopAction(state, source, route.key); + if (action) { + const sourceIndex = state.routes.findIndex((current) => current.key === source); + // UIKit owns this pop. Keep these children attached until its + // dismissal callback, so the early router update cannot enqueue a second pop. + const poppedKeys = state.routes + .slice(sourceIndex - action.payload.count + 1, sourceIndex + 1) + .map((popped) => popped.key); + setScreens((current) => ({ + ...current, + nativeCompletingPops: new Set([ + ...current.nativeCompletingPops, + ...poppedKeys, + ]), + })); + nativePopSource.current = null; + props.navigation.dispatch(action); + } + } props.navigation.emit({ type: "transitionEnd", target: route.key, data: { closing: false }, - }) - } - onWillDisappear={() => + }); + }} + onWillDisappear={() => { + const state = props.navigation.getState(); + // JS pushes/pops already changed history. Only native Back leaves + // the disappearing card at the router's active index. + if (state.index > 0 && state.routes[state.index]?.key === route.key) { + nativePopSource.current = route.key; + } props.navigation.emit({ type: "transitionStart", target: route.key, data: { closing: true }, - }) - } + }); + }} onDidDisappear={() => props.navigation.emit({ type: "transitionEnd", diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index 1f5c00c65551..6e9585586866 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -3,6 +3,7 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati import { describe, expect, it } from "vite-plus/test"; import { + nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, projectWorkspaceStack, @@ -128,6 +129,63 @@ describe("native workspace dismissal", () => { }); }); +describe("native card pop handoff", () => { + const routerOptions = { + routeNames: history([home]).routeNames, + routeParamList: {}, + routeGetIdList: {}, + }; + it("keeps a newly opened screen when an earlier swipe dismissal arrives late", () => { + const state = history([home, thread, files, settings]); + const next = StackRouter({}).getStateForAction( + state, + nativeStackPopAction(state, files.key)!, + routerOptions, + ); + expect(next?.routes).toEqual([home, thread, settings]); + expect(next?.index).toBe(2); + if (!next || next.stale !== false) throw new Error("Expected a rehydrated router history"); + expect(nativeStackPopAction(next, files.key)).toBeNull(); + }); + it("acknowledges a native pop to an earlier page without consuming a newer push", () => { + const state = history([home, thread, files, settings]); + const next = StackRouter({}).getStateForAction( + state, + nativeStackPopAction(state, files.key, home.key)!, + routerOptions, + ); + expect(next?.routes).toEqual([home, settings]); + expect(next?.index).toBe(1); + }); + it("allows reopening the same page after its destination finishes appearing", () => { + const state = history([home, thread, files]); + const router = StackRouter({}); + const popped = router.getStateForAction( + state, + nativeStackPopAction(state, files.key, thread.key)!, + routerOptions, + ); + if (!popped || popped.stale !== false) throw new Error("Expected a rehydrated router history"); + const reopened = router.getStateForAction( + popped, + { type: "NAVIGATE", payload: { name: files.name, params: files.params } }, + routerOptions, + ); + expect(reopened?.routes.map((route) => route.name)).toEqual(["Home", "Thread", "ThreadFiles"]); + expect(reopened?.routes.at(-1)?.key).not.toBe(files.key); + if (!reopened || reopened.stale !== false) + throw new Error("Expected a rehydrated router history"); + expect(nativeStackPopAction(reopened, files.key)).toBeNull(); + }); + it("leaves history intact when a swipe is cancelled or its source is already gone", () => { + const state = history([home, thread, files]); + expect(nativeStackPopAction(state, files.key, files.key)).toBeNull(); + expect(nativeStackPopAction(state, files.key, settings.key)).toBeNull(); + expect(nativeStackPopAction(state, home.key)).toBeNull(); + expect(nativeStackPopAction({ ...state, index: 1 }, files.key)).toBeNull(); + }); +}); + describe("v5 stack handoff", () => { it("releases a completed native pop when the router acknowledges it", () => { const completed = new Set([files.key]); diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 79e66290fe6f..e3e716c8c1c2 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -44,6 +44,33 @@ export function nativeWorkspacePopAction( : null; } +/** A native card pop must preserve pushes made before its completion event reaches JS. */ +export function nativeStackPopAction( + state: Pick, + dismissedKey: string, + destinationKey?: string, +) { + const sourceIndex = state.routes.findIndex((route) => route.key === dismissedKey); + const destinationIndex = + destinationKey === undefined + ? sourceIndex - 1 + : state.routes.findIndex((route) => route.key === destinationKey); + if ( + sourceIndex <= 0 || + sourceIndex > state.index || + destinationIndex < 0 || + destinationIndex >= sourceIndex + ) { + return null; + } + return { + type: "POP" as const, + payload: { count: sourceIndex - destinationIndex }, + source: dismissedKey, + target: state.key, + }; +} + /** Group pushes with the modal that owns their native stack. */ export function partitionStackPresentations( routes: readonly T[], From fd25c42ae6e56a82be7219950a746572b1c4d0e9 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:26:35 +0300 Subject: [PATCH 014/377] fix(server): keep newly discovered models out of legacy groups (#14314) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../Drivers/AntigravityDriver.test.ts | 4 +-- .../src/provider/Drivers/AntigravityDriver.ts | 3 -- .../server/src/provider/ModelManifest.test.ts | 30 ++++++++++++++++++- apps/server/src/provider/ModelManifest.ts | 5 +--- apps/server/src/provider/model-manifest.json | 30 +++++++++++++++++-- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 5e0581f5bdd3..e0c52801bc81 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -325,9 +325,7 @@ it.layer(layerTest)("AntigravityDriver", (it) => { "gemini-test-high", ]); expect(snapshot.models[0]?.aliases).toContain(ANTIGRAVITY_DEFAULT_MODEL); - // The mock catalog is not in the manifest's current list, so it folds - // under the legacy section like an old Codex model would. - expect(snapshot.models.every((model) => model.isLegacy === true)).toBe(true); + expect(snapshot.models.every((model) => model.isLegacy !== true)).toBe(true); expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["plan", "logout"]); expect(snapshot.supportsTextGeneration).toBe(true); h.controls.selected = h.second; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 7a10b4a725cd..29e103c453b6 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -150,9 +150,6 @@ export const AntigravityDriver: ProviderDriver modelManifest.current.pipe( Effect.map((manifest) => diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index e11e1bb1b382..8b8164387f26 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -34,6 +34,12 @@ describe("classifyModels", () => { const manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: { codex: ["gpt-test"] }, + providers: { + codex: { + profiles: {}, + models: [{ slug: "gpt-old", name: "Old", status: "legacy" }], + }, + }, }; const models = [ model({ slug: "openai.gpt-test", isLegacy: true }), @@ -50,16 +56,23 @@ describe("classifyModels", () => { ], ); }); - it("flags non-current models, clears stale flags, and skips custom models", () => { + it("flags only known legacy models, clears stale flags, and skips custom models", () => { const manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: { codex: ["current-a", "current-b"] }, + providers: { + codex: { + profiles: {}, + models: [{ slug: "old-model", name: "Old", status: "legacy" }], + }, + }, }; const models = [ model({ slug: "current-a" }), // Stale flag from a previous classification pass must be cleared. model({ slug: "current-b", isLegacy: true }), model({ slug: "old-model" }), + model({ slug: "new-release", isLegacy: true }), // Custom models are user-defined and never reclassified. model({ slug: "my-own-model", isCustom: true }), ]; @@ -72,10 +85,25 @@ describe("classifyModels", () => { ["current-a", false], ["current-b", false], ["old-model", true], + ["new-release", false], ["my-own-model", false], ], ); }); + it.each(["codex", "antigravity"])( + "keeps newly discovered %s models current when the manifest has no catalog", + (driverKind) => { + const models = [model({ slug: "new-release", isLegacy: true })]; + assert.deepStrictEqual( + ModelManifest.classifyModels( + models, + { version: 1, currentModels: { [driverKind]: ["known-current"] } }, + ProviderDriverKind.make(driverKind), + ), + [model({ slug: "new-release" })], + ); + }, + ); }); describe("applyManifestDefault", () => { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index fd0fa1042a29..c22efb8a094d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -221,10 +221,7 @@ function isLegacyModel( const catalogModel = catalog?.find((model) => model.slug === slug) ?? catalog?.find((model) => model.slug === family); - if (catalogModel) return catalogModel.status === "legacy"; - const currentModels = manifest.currentModels[driverKind]; - if (!currentModels) return false; - return !currentModels.includes(slug) && !currentModels.includes(family); + return catalogModel?.status === "legacy"; } /** diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 5af2454fe6f5..36b7727d8ecb 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-07T19:00:00Z", + "updatedAt": "2026-10-08T14:44:57Z", "compatibility": [ { "driver": "codex", @@ -109,6 +109,16 @@ "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { + "codex": { + "profiles": {}, + "models": [ + { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" }, + { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, + { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, + { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" } + ] + }, "claudeAgent": { "defaults": { "chat": "claude-fable-5-1" @@ -777,7 +787,23 @@ "slug": "gemini-3.8-flash-low", "name": "Gemini 3.8 Flash (Low)", "status": "current" - } + }, + { "slug": "gemini-3.7-flash-high", "name": "Gemini 3.7 Flash (High)", "status": "legacy" }, + { + "slug": "gemini-3.7-flash-medium", + "name": "Gemini 3.7 Flash (Medium)", + "status": "legacy" + }, + { "slug": "gemini-3.7-flash-low", "name": "Gemini 3.7 Flash (Low)", "status": "legacy" }, + { "slug": "gemini-3.6-flash-high", "name": "Gemini 3.6 Flash (High)", "status": "legacy" }, + { + "slug": "gemini-3.6-flash-medium", + "name": "Gemini 3.6 Flash (Medium)", + "status": "legacy" + }, + { "slug": "gemini-3.6-flash-low", "name": "Gemini 3.6 Flash (Low)", "status": "legacy" }, + { "slug": "gemini-pro-agent", "name": "Gemini 3.1 Pro (High)", "status": "legacy" }, + { "slug": "gemini-3.1-pro-low", "name": "Gemini 3.1 Pro (Low)", "status": "legacy" } ] } } From df616cc5491dcc7dd80d3ade8decd88f7a652220 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 12:30:47 -0700 Subject: [PATCH 015/377] feat(editors): open remote projects in JetBrains IDEs over SSH (#17271) Co-authored-by: Claude Opus 5.5 --- .../src/electron/ElectronShell.test.ts | 22 +++- apps/desktop/src/electron/ElectronShell.ts | 14 +- apps/web/src/remoteOpen.test.ts | 19 ++- packages/contracts/src/editor.ts | 124 +++++++++++++++--- 4 files changed, 155 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 8ba8082f0e57..580a8b95684d 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -106,6 +106,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the JetBrains Toolbox ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const url = + "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject"; + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -114,9 +128,12 @@ describe("ElectronShell", () => { const results = yield* Effect.all([ electronShell.openExternal("zed://extension/attacker"), electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"), + electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"), + electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); @@ -134,9 +151,10 @@ describe("ElectronShell", () => { "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), + electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"), ]); - assert.deepEqual(results, [false, false, false]); + assert.deepEqual(results, [false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9be6d4ecf55b..5504125ec202 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -12,8 +12,8 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`) must reach the OS handler; every other non-web -// scheme stays blocked. +// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must +// reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -31,9 +31,13 @@ const isRemoteEditorUrl = (url: URL) => url.password.length === 0 && (url.protocol === "zed:" ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) - : url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length); + : url.protocol === "jetbrains:" + ? url.host === "gateway" && + url.pathname === "/ssh/environment" && + (url.searchParams.get("h") ?? "").length > 0 + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 0f636a526cef..d64161c66917 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -160,8 +160,25 @@ describe("buildRemoteOpenUrl", () => { ); }); + it("builds a Toolbox ssh deep link for JetBrains IDEs", () => { + expect( + buildRemoteOpenUrl({ + editor: "pycharm", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol.tail1234.ts.net&launchIde=true&ideHint=PY&projectHint=%2Fhome%2Ftheo%2Fcode%2Fmy+repo", + ); + expect( + buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe( + "jetbrains://gateway/ssh/environment?h=sol&launchIde=true&ideHint=IU&projectHint=C%3A%2FUsers%2Ftheo", + ); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 55f16e1e0124..75dca95786bf 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -17,6 +17,12 @@ type EditorDefinition = { * `zed://ssh/` shape. */ readonly remoteScheme?: string; + /** + * JetBrains product code (`IU`, `PY`, ...). JetBrains IDEs open remote + * projects through the Toolbox App's `jetbrains://gateway/ssh/...` link, + * which uses the code to choose the IDE backend. + */ + readonly jetbrainsProductCode?: string; }; export const EDITORS = [ @@ -67,18 +73,90 @@ export const EDITORS = [ commands: ["antigravity-ide", "agy-ide"], launchStyle: "goto", }, - { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, - { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, - { id: "clion", label: "CLion", commands: ["clion"], launchStyle: "line-column" }, - { id: "datagrip", label: "DataGrip", commands: ["datagrip"], launchStyle: "line-column" }, - { id: "dataspell", label: "DataSpell", commands: ["dataspell"], launchStyle: "line-column" }, - { id: "goland", label: "GoLand", commands: ["goland"], launchStyle: "line-column" }, - { id: "phpstorm", label: "PhpStorm", commands: ["phpstorm"], launchStyle: "line-column" }, - { id: "pycharm", label: "PyCharm", commands: ["pycharm"], launchStyle: "line-column" }, - { id: "rider", label: "Rider", commands: ["rider"], launchStyle: "line-column" }, - { id: "rubymine", label: "RubyMine", commands: ["rubymine"], launchStyle: "line-column" }, - { id: "rustrover", label: "RustRover", commands: ["rustrover"], launchStyle: "line-column" }, - { id: "webstorm", label: "WebStorm", commands: ["webstorm"], launchStyle: "line-column" }, + { + id: "idea", + label: "IntelliJ IDEA", + commands: ["idea"], + launchStyle: "line-column", + jetbrainsProductCode: "IU", + }, + { + id: "aqua", + label: "Aqua", + commands: ["aqua"], + launchStyle: "line-column", + jetbrainsProductCode: "QA", + }, + { + id: "clion", + label: "CLion", + commands: ["clion"], + launchStyle: "line-column", + jetbrainsProductCode: "CL", + }, + { + id: "datagrip", + label: "DataGrip", + commands: ["datagrip"], + launchStyle: "line-column", + jetbrainsProductCode: "DB", + }, + { + id: "dataspell", + label: "DataSpell", + commands: ["dataspell"], + launchStyle: "line-column", + jetbrainsProductCode: "DS", + }, + { + id: "goland", + label: "GoLand", + commands: ["goland"], + launchStyle: "line-column", + jetbrainsProductCode: "GO", + }, + { + id: "phpstorm", + label: "PhpStorm", + commands: ["phpstorm"], + launchStyle: "line-column", + jetbrainsProductCode: "PS", + }, + { + id: "pycharm", + label: "PyCharm", + commands: ["pycharm"], + launchStyle: "line-column", + jetbrainsProductCode: "PY", + }, + { + id: "rider", + label: "Rider", + commands: ["rider"], + launchStyle: "line-column", + jetbrainsProductCode: "RD", + }, + { + id: "rubymine", + label: "RubyMine", + commands: ["rubymine"], + launchStyle: "line-column", + jetbrainsProductCode: "RM", + }, + { + id: "rustrover", + label: "RustRover", + commands: ["rustrover"], + launchStyle: "line-column", + jetbrainsProductCode: "RR", + }, + { + id: "webstorm", + label: "WebStorm", + commands: ["webstorm"], + launchStyle: "line-column", + jetbrainsProductCode: "WS", + }, { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; @@ -98,7 +176,8 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; -const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; +const remoteSchemeOf = (editor: EditorDefinition): string | undefined => + editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains"); /** Editors that can open a remote workspace via an SSH deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => @@ -112,9 +191,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { /** * Builds a `://vscode-remote/ssh-remote+` deep link (Zed - * takes `zed://ssh/`) that opens `absolutePath` on `host` in the - * local editor over SSH. Returns undefined for editors without remote - * deep-link support. + * takes `zed://ssh/`, JetBrains IDEs a Toolbox App + * `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on + * `host` in the local editor over SSH. Returns undefined for editors without + * remote deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -125,6 +205,18 @@ export const buildRemoteOpenUrl = (input: { if (scheme === undefined) { return undefined; } + const editor = EDITORS.find((candidate) => candidate.id === input.editor); + if (editor !== undefined && "jetbrainsProductCode" in editor) { + // Like the VS Code link, no user or port: the SSH config entry for `host` + // supplies them. A bare product code lets Toolbox pick the backend build. + const params = new URLSearchParams({ + h: input.host, + launchIde: "true", + ideHint: editor.jetbrainsProductCode, + projectHint: input.absolutePath.replaceAll("\\", "/"), + }); + return `${scheme}://gateway/ssh/environment?${params.toString()}`; + } // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; From 740f591221bd9651f172237d68b7daba9d050dbd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 12:41:56 -0700 Subject: [PATCH 016/377] test(desktop): expect JetBrains IDEs among remote editors (#17291) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/ipc/methods/window.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 41d93ecc4969..72f631d162d1 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -341,6 +341,6 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( ); assert.include(editors, "cursor"); assert.include(editors, "vscode"); - assert.notInclude(editors, "webstorm"); + assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); From cdd331b6c3f78bf24b039b09937b837d2d71a4e7 Mon Sep 17 00:00:00 2001 From: Grzegorz Mandziak <4248465+alimek@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:46:02 +0200 Subject: [PATCH 017/377] fix(server): recognize authenticated GitHub Enterprise hosts (#11059) Co-authored-by: Claude Code Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../pullRequest/PullRequestService.test.ts | 47 +++++ .../GitHubSourceControlProvider.test.ts | 199 +++++++++++++++++- .../GitHubSourceControlProvider.ts | 75 +++++-- .../SourceControlProviderRegistry.test.ts | 80 ++++++- .../gitHubRepositoryResolution.ts | 4 +- docs/user/source-control.md | 3 + 6 files changed, 388 insertions(+), 20 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c2a46ed88978..f9015ecbd9ef 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -516,6 +516,53 @@ function makeService(input: { ); } +it.effect("lists GitHub Enterprise PRs for a stored unknown repository after host discovery", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "enterprise", + workspaceRoot: "/repo", + repository: "team/project", + provider: "unknown", + host: "code.example.test", + }), + ], + providers: [ + fakeProvider("github", { + /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */ + listChangeRequests: ({ host, repository }) => { + assert.strictEqual(host, "code.example.test"); + assert.strictEqual(repository, "team/project"); + return Effect.succeed({ + items: [changeRequest(42, "2026-07-05T00:00:00Z")], + truncated: false, + continues: false, + }); + }, + }), + ], + /** Stands in for discovery claiming the custom host as GitHub. */ + resolveHandle: ({ context }) => { + assert.ok(context); + return Effect.succeed({ + context: { + ...context, + provider: { ...context.provider, kind: "github", name: "GitHub Self-Hosted" }, + }, + provider: undefined as never, + }); + }, + }); + const result = yield* service.list({ state: "open" }); + assert.deepStrictEqual( + result.entries.map(({ host, number }) => [host, number]), + [["code.example.test", 42]], + ); + }), +); + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index caa91a5ac937..602acc9d1e27 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -74,6 +75,7 @@ function harness(input: { readonly remotes: string; readonly api: Partial; readonly localBranches?: ReadonlyArray; + readonly resolved?: string; /** Fails the local branch listing, the first git read a checkout makes. */ readonly gitFailure?: unknown; }) { @@ -110,7 +112,9 @@ function harness(input: { const process = Layer.mock(VcsProcess.VcsProcess)({ run: (args) => Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + args.args[0] === "remote" + ? processOutput(input.remotes) + : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), ), }); const layer = Layer.mergeAll( @@ -965,3 +969,196 @@ it("names the environment token that overrides the Settings choice", () => { ); assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); }); + +it.live.each([ + "git@code.example.test:team/project.git", + "ssh://git@code.example.test/team/project.git", + "https://code.example.test/team/project.git", +])("keeps Enterprise operations on the selected remote: %s", (remoteUrl) => { + const graphql: GitHubApi.GitHubGraphQlInput[] = []; + const rest: GitHubApi.GitHubRestInput[] = []; + const api = { + graphql: (input: GitHubApi.GitHubGraphQlInput) => + Effect.sync(() => { + graphql.push(input); + const pr = { + ...node(42, "feature", "team"), + url: "https://code.example.test/team/project/pull/42", + isCrossRepository: false, + headRepository: { name: "project", nameWithOwner: "team/project" }, + }; + return encodeJson({ + data: { + repository: + input.operation === "getPullRequest" ? { pullRequest: pr } : { h0: { nodes: [pr] } }, + }, + }); + }), + rest: (input: GitHubApi.GitHubRestInput) => + Effect.sync(() => { + rest.push(input); + return restResponse({ + full_name: "team/project", + html_url: `https://${input.host}/team/project`, + ssh_url: `git@${input.host}:team/project.git`, + default_branch: "main", + }); + }), + }; + const { layer, git } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:team/project.git"], + ["enterprise", remoteUrl], + ), + api, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Enterprise PR"); + const input = { + cwd: "/repo", + context: { + provider: { + kind: "github" as const, + name: "GitHub Self-Hosted", + baseUrl: "https://code.example.test", + }, + remoteName: "enterprise", + remoteUrl, + }, + }; + for (const state of ["open", "closed"] as const) { + const prs = yield* provider.listChangeRequests({ + ...input, + state, + headSelector: "feature", + }); + assert.strictEqual(prs[0]?.url, "https://code.example.test/team/project/pull/42"); + } + yield* provider.getChangeRequest({ ...input, reference: "42" }); + yield* provider.getChangeRequest({ ...input, reference: "feature" }); + yield* provider.createChangeRequest({ + ...input, + baseRefName: "main", + headSelector: "feature", + title: "PR", + bodyFile, + }); + assert.strictEqual(yield* provider.getDefaultBranch(input), "main"); + yield* provider.checkoutChangeRequest({ ...input, reference: "42" }); + yield* provider.getRepositoryCloneUrls({ ...input, repository: "team/other" }); + yield* provider.getRepositoryCloneUrls({ ...input, repository: "github.com/team/explicit" }); + assert.strictEqual(graphql.length, 5); + for (const request of graphql) { + assert.strictEqual(request.host, "code.example.test"); + assert.strictEqual(request.variables?.owner, "team"); + assert.strictEqual(request.variables?.name, "project"); + } + assert.deepStrictEqual( + rest.map(({ host, path }) => [host, path]), + [ + ["code.example.test", "repos/team/project/pulls"], + ["code.example.test", "repos/team/project"], + ["code.example.test", "repos/team/other"], + ["github.com", "repos/team/explicit"], + ], + ); + assert.deepStrictEqual(git.find(([name]) => name === "fetchRemoteTrackingBranch")?.[1], { + cwd: "/repo", + remoteName: "enterprise", + remoteBranch: "feature", + }); + }).pipe(Effect.provide(layer), Effect.provideService(HostProcessEnvironment, {}), Effect.scoped); +}); + +it.effect.each([ + { host: "github.com", resolved: "", envRepository: "", expected: "acme/web" }, + { host: "code.example.test", resolved: "", envRepository: "", expected: "acme/web" }, + { + host: "github.com", + resolved: "remote.origin.gh-resolved base", + envRepository: "", + expected: "me/web", + }, + { + host: "code.example.test", + resolved: "remote.origin.gh-resolved base", + envRepository: "", + expected: "me/web", + }, + { + host: "github.com", + resolved: "remote.upstream.gh-resolved acme/other", + envRepository: "", + expected: "acme/other", + }, + { + host: "code.example.test", + resolved: "remote.upstream.gh-resolved acme/other", + envRepository: "", + expected: "acme/other", + }, + { host: "github.com", resolved: "", envRepository: "chosen/repo", expected: "chosen/repo" }, + { + host: "code.example.test", + resolved: "", + envRepository: "chosen/repo", + expected: "chosen/repo", + }, +])( + "preserves repository precedence on $host: $resolved $envRepository", + ({ host, resolved, envRepository, expected }) => { + const targets: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", `git@${host}:me/web.git`], + ["upstream", `git@${host}:acme/web.git`], + ), + resolved, + api: { + rest: (input) => + Effect.sync(() => { + targets.push(`${input.host}/${input.path}`); + return restResponse({ + full_name: expected, + html_url: `https://${host}/${expected}`, + ssh_url: `git@${host}:${expected}.git`, + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped(); + yield* fs.writeFileString(bodyFile, "Body"); + const input = { + cwd: "/repo", + context: { + provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl: `git@${host}:me/web.git`, + }, + }; + yield* provider.getDefaultBranch(input); + yield* provider.createChangeRequest({ + ...input, + baseRefName: "main", + headSelector: "me:feature", + title: "PR", + bodyFile, + }); + assert.deepStrictEqual(targets, [ + `${host}/repos/${expected}`, + `${host}/repos/${expected}/pulls`, + ]); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcessEnvironment, { GH_REPO: envRepository }), + Effect.scoped, + ); + }, +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 223a2ec58a26..da3d53330864 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -30,6 +30,7 @@ import { type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; import { + gitHubApiHostForRemote, parseFetchRemotes, parseGitHubRepositorySelector, parsePullRequestReference, @@ -255,7 +256,30 @@ export const makeDiscovery = Effect.gen(function* () { }, } satisfies SourceControlProviderDiscoveryItem; }), - refineUnknownRemote: () => Effect.succeed(null), + /** + * Claims a custom host GitHub holds a credential for, from whichever source supplies it + * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its + * error says so instead of "unsupported host". + */ + refineUnknownRemote: ({ context }) => { + // Identity resolution also probes providers before a web base URL is known. + const url = URL.parse(context.provider.baseUrl); + if (!url?.host) return Effect.succeed(null); + return api.credential(url.host).pipe( + Effect.as(true), + Effect.catchTags({ GitHubHostDisabledError: () => Effect.succeed(true) }), + Effect.orElseSucceed(() => false), + Effect.map((known) => + known + ? ({ + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: context.provider.baseUrl, + } as const) + : null, + ), + ); + }, } satisfies SourceControlManagedCliDiscoverySpec; }); @@ -469,13 +493,18 @@ export const make = Effect.gen(function* () { }); /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is + * a fallback when the checkout's remotes cannot resolve that repository. */ const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( - function* (input: { readonly cwd: string; readonly host?: string | undefined }) { + function* (input: { + readonly cwd: string; + readonly host?: string | undefined; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; + }) { const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + const hostHint = input.host ?? contextHost(input.context); + const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase(); if (envRepository) { const locator = parseGitHubRepositorySelector(envRepository, defaultHost); if (locator !== null) return locator; @@ -487,10 +516,18 @@ export const make = Effect.gen(function* () { const { host, locator } = resolveGitHubRepository({ remotes: remotes?.exitCode === 0 ? remotes.stdout : "", resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - hostHint: input.host, + hostHint, defaultHost, }); if (locator !== null) return locator; + if (input.context) { + const remote = normalizeGitRemoteUrl(input.context.remoteUrl); + const fallback = parseGitHubRepositorySelector( + `${host}/${remote.slice(remote.indexOf("/") + 1)}`, + host, + ); + if (fallback !== null) return fallback; + } return yield* failure( `No GitHub repository on ${host} was found among this checkout's git remotes.`, ); @@ -583,10 +620,11 @@ export const make = Effect.gen(function* () { readonly headSelector: string; readonly state: PullRequestListState; readonly limit: number; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; readonly host?: string | undefined; readonly allowReserve: boolean; }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const locator = yield* resolveRepository(input); const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the // owner is matched on the rows it returns. @@ -613,6 +651,7 @@ export const make = Effect.gen(function* () { function* (input: { readonly cwd: string; readonly reference: string; + readonly context?: SourceControlProvider.SourceControlProviderContext | undefined; readonly host?: string | undefined; }) { const parsed = parsePullRequestReference(input.reference); @@ -625,6 +664,7 @@ export const make = Effect.gen(function* () { state, limit: 1, host: input.host, + context: input.context, allowReserve: true, }); const [open] = yield* lookup("open"); @@ -632,10 +672,7 @@ export const make = Effect.gen(function* () { if (found === undefined) return yield* notFound("No pull request has this head branch."); return found; } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); const decodeDetail = "GitHub returned an invalid pull request."; const decoded = yield* graphqlJson( { @@ -715,11 +752,11 @@ export const make = Effect.gen(function* () { readonly cwd: string; readonly reference: string; readonly force?: boolean; + readonly context?: SourceControlProvider.SourceControlProviderContext; }) { const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const pullRequest = yield* readPullRequest(input); + const base = reference.kind === "url" ? reference.locator : yield* resolveRepository(input); const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; const isCrossRepository = @@ -735,8 +772,10 @@ export const make = Effect.gen(function* () { const remoteFor = (nameWithOwner: string) => parseFetchRemotes(remotes).find( (remote) => + (gitHubApiHostForRemote(remote.url) ?? + normalizeGitRemoteUrl(remote.url).split("/")[0]) === base.host && normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), + nameWithOwner.toLowerCase(), )?.name ?? null; const baseRemote = Effect.suspend(() => { const known = remoteFor(baseNameWithOwner); @@ -920,6 +959,7 @@ export const make = Effect.gen(function* () { state: input.state, limit: input.limit ?? (input.state === "open" ? 1 : 20), host: contextHost(input.context), + context: input.context, allowReserve, }), ), @@ -937,7 +977,7 @@ export const make = Effect.gen(function* () { ), createChangeRequest: (input) => Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); + const locator = yield* resolveRepository(input); const body = yield* fileSystem .readFileString(input.bodyFile) .pipe( @@ -969,7 +1009,7 @@ export const make = Effect.gen(function* () { ), getRepositoryCloneUrls: (input) => Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + const fallbackHost = (yield* resolveRepository(input).pipe( Effect.map((locator) => locator.host), Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), )).toLowerCase(); @@ -1020,6 +1060,7 @@ export const make = Effect.gen(function* () { const locator = yield* resolveRepository({ cwd: input.cwd, host: contextHost(input.context), + context: input.context, }); const repository = yield* readRepository(locator); const branch = repository.default_branch?.trim() ?? ""; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index f715eff1dea0..705c38b847b7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import { ChildProcessSpawner } from "effect/process"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -16,6 +17,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -97,7 +99,11 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), + Layer.mock(GitHubApi.GitHubApi)({ + // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. + credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), + ...input.githubApi, + }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), @@ -355,3 +361,75 @@ it.effect( } }).pipe(Effect.scoped), ); + +it.effect.each([ + { name: "a credential", credential: "found", expected: "github" }, + // Claimed so the error says the host is turned off, not that it is unsupported. + { name: "a host turned off in Settings", credential: "disabled", expected: "github" }, + { name: "no credential", credential: "missing", expected: "unknown" }, +] as const)("resolves custom-host remotes with $name", (scenario) => + Effect.gen(function* () { + const hosts: Array = []; + const commands: Array = []; + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], + process: { + run: ({ command }) => { + commands.push(command); + return Effect.succeed(processOutput("")); + }, + }, + githubApi: { + credential: (host) => { + hosts.push(host); + return scenario.credential === "found" + ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) + : scenario.credential === "disabled" + ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host })) + : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); + }, + }, + }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(handle.provider.kind, scenario.expected); + assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); + assert.deepStrictEqual(hosts, ["code.example.test"]); + // Recognition goes through the credential layer; gh is only one of its sources. + assert.notInclude(commands, "gh"); + }), +); + +it.effect("skips GitHub discovery for the identity resolver's empty base URL", () => + Effect.gen(function* () { + const hosts: string[] = []; + const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git"; + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: remoteUrl }], + githubApi: { + credential: (host) => { + hosts.push(host); + return Effect.succeed({ + token: Redacted.make("token"), + fingerprint: `${host}:fingerprint`, + }); + }, + }, + }); + const context = { + provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" }, + remoteName: "origin", + remoteUrl, + }; + const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context }); + assert.deepStrictEqual(unresolved.context, context); + assert.deepStrictEqual(hosts, []); + + const resolved = yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(resolved.provider.kind, "github"); + assert.strictEqual( + resolved.context?.provider.baseUrl, + "https://githubenterprise.dev.example.com", + ); + assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]); + }), +); diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts index 8d4cbf038bfd..21f68b4cdb20 100644 --- a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts @@ -99,7 +99,9 @@ export function resolveGitHubRepository(input: { // the ones on this host (an SSH alias counts as its API host). const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); const candidates = fetchRemotes - .filter((remote) => remote.host === host) + .filter( + (remote) => remote.host === host || normalizeGitRemoteUrl(remote.url).split("/")[0] === host, + ) .toSorted((left, right) => { const l = rank(left.name); const r = rank(right.name); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 344f4c97917d..2e6785db07b9 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -24,6 +24,9 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence over that choice; a host turned off stays off either way. +For GitHub Enterprise, sign in with `gh auth login --hostname YOUR_HOST`. T3 Code treats a +custom server name as GitHub once it has a credential for that host. + ### Forgejo and Gitea Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or From 2a93885bac5798a79d55069a0b5dc3e53c6176bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 13:09:30 -0700 Subject: [PATCH 018/377] fix(connect): relay client updates itself and skips incompatible cloudflared (#17275) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/connect.test.ts | 3 + .../src/cloud/ManagedEndpointRuntime.test.ts | 555 ++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 140 +++++ docs/internals/t3-connect.md | 24 + packages/shared/src/relayClient.test.ts | 223 ++++++- packages/shared/src/relayClient.ts | 246 ++++++-- 6 files changed, 1132 insertions(+), 59 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f3cc88d1b58a..514a9b56a2a5 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed installCalls += 1; return managedExecutable; }), + pruneManagedVersions: Effect.void, }, () => Effect.succeed(false), () => Effect.void, @@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download }), ), ), + pruneManagedVersions: Effect.void, }, () => Effect.succeed(true), (event) => @@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () => resolve: Effect.succeed(managedExecutable), install: Effect.die("unexpected install"), installWithProgress: () => Effect.die("unexpected install"), + pruneManagedVersions: Effect.void, }, () => Effect.sync(() => { diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 4b06ae2124ff..3a16c58fb60c 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed( }), install: Effect.die("unused"), installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, }), ); @@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => { }), install: Effect.die("unused"), installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, }), ), ); @@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => { expect(spawn).not.toHaveBeenCalled(); }), ); + + it.effect( + "runs an older relay client while the pinned one installs, then restarts on it and prunes", + () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const releaseInstall = yield* Deferred.make(); + const pruned = yield* Deferred.make(); + const spawned = yield* Queue.unbounded(); + const killed: Array = []; + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; + yield* Queue.offer(spawned, executable); + const handle = makeHandle({ + pid: executable === pinnedClient.executablePath ? 2 : 1, + onKill: () => killed.push(executable), + ...(executable === pinnedClient.executablePath + ? { output: Stream.fromQueue(output) } + : {}), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + installed = true; + return pinnedClient; + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Deferred.succeed(pruned, undefined).pipe(Effect.asVoid), + }), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + expect(status).toMatchObject({ status: "running", pid: 1 }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + + yield* Deferred.succeed(releaseInstall, undefined); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + expect(killed).toEqual([olderClient.executablePath]); + + yield* Queue.offer( + output, + new TextEncoder().encode( + "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Deferred.await(pruned); + }), + ); + + it.effect("installs a missing relay client in the background and then starts it", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 3, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => + installed + ? pinnedClient + : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, + ), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + // The first attempt reports the gap; the install then starts the connector itself. + expect(status).toMatchObject({ status: "failed", failure: "not-installed" }); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + }), + ); + + it.effect("leaves a PATH relay client alone", () => + Effect.gen(function* () { + const install = vi.fn(() => Effect.die("unexpected install")); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ pid: 8, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "/opt/homebrew/bin/cloudflared", + source: "path", + version: "2026.10.0", + }), + install: Effect.suspend(install), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "running", pid: 8 }); + yield* TestClock.adjust(Duration.minutes(30)); + expect(install).not.toHaveBeenCalled(); + }), + ); + + it.effect("leaves an override relay client alone", () => + Effect.gen(function* () { + const install = vi.fn(() => Effect.die("unexpected install")); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ pid: 4, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "/opt/cloudflared", + source: "override", + version: "2025.10.0", + }), + install: Effect.suspend(install), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "running", pid: 4 }); + yield* Effect.yieldNow; + expect(install).not.toHaveBeenCalled(); + }), + ); + + it.effect("retries a failed background install while the older connector keeps running", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + let installAttempts = 0; + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Queue.offer( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 5, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Effect.suspend(() => { + installAttempts += 1; + if (installAttempts === 1) { + // A real download fails some time after it starts. + return Effect.sleep(Duration.seconds(30)).pipe( + Effect.andThen( + Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "download_failed", + message: "offline", + }), + ), + ), + ); + } + installed = true; + return Effect.succeed(pinnedClient); + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + yield* TestClock.adjust(Duration.seconds(30)); + expect(installAttempts).toBe(1); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + expect(installAttempts).toBe(2); + }), + ); + + it.effect("requests recovery when the updated relay client fails to start", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const releaseInstall = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; + if (executable === pinnedClient.executablePath) { + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }); + } + const handle = makeHandle({ pid: 6, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + installed = true; + return pinnedClient; + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const; + expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 }); + + yield* Deferred.succeed(releaseInstall, undefined); + expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + }), + ); + + it.effect("restarts a self-updated pinned-folder connector once the pin is reinstalled", () => + Effect.gen(function* () { + const executablePath = "/managed/pinned/cloudflared"; + let version = "2026.9.3"; + const releaseInstall = yield* Deferred.make(); + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + yield* Queue.offer(spawned, version); + const handle = makeHandle({ pid: 7, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const current = () => + ({ status: "available", executablePath, source: "managed", version }) as const; + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(current), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + version = RelayClient.CLOUDFLARED_VERSION; + return current(); + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe("2026.9.3"); + yield* Deferred.succeed(releaseInstall, undefined); + expect(yield* Queue.take(spawned)).toBe(RelayClient.CLOUDFLARED_VERSION); + }), + ); + + it.effect("retries a failed install when no relay client is installed", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + let installAttempts = 0; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 9, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => + installed + ? pinnedClient + : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, + ), + install: Effect.suspend(() => { + installAttempts += 1; + if (installAttempts === 1) { + return Effect.fail( + new RelayClient.RelayClientInstallError({ + reason: "download_failed", + message: "offline", + }), + ); + } + installed = true; + return Effect.succeed(pinnedClient); + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "failed", failure: "not-installed" }); + yield* Effect.yieldNow; + expect(installAttempts).toBe(1); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + expect(installAttempts).toBe(2); + }), + ); + + it.effect("restarts on the pin when the first post-install probe failed", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + // The first resolve after the install misses the new binary, as when a + // scanner briefly holds it; later resolves see it. + let missedProbes = 0; + const spawned = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Queue.offer( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 10, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => { + if (installed && missedProbes === 0) { + missedProbes += 1; + return olderClient; + } + return installed ? pinnedClient : olderClient; + }), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); + expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); + yield* Effect.yieldNow; + // The missed probe kept the older connector running. + expect(Option.isNone(yield* Queue.poll(spawned))).toBe(true); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); + }), + ); + + it.effect("starts the connector when the pin landed but its first probe failed", () => + Effect.gen(function* () { + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + const missing = { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }; + let installed = false; + // The resolve right after the install misses the new binary. + let missedProbes = 0; + const spawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + yield* Deferred.succeed( + spawned, + ChildProcess.isStandardCommand(command) ? command.command : "", + ); + const handle = makeHandle({ pid: 11, onKill: () => {} }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => { + if (!installed) return missing; + if (missedProbes === 0) { + missedProbes += 1; + return missing; + } + return pinnedClient; + }), + install: Effect.sync(() => { + installed = true; + return pinnedClient; + }), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + + expect( + yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), + ).toMatchObject({ status: "failed", failure: "not-installed" }); + yield* Effect.yieldNow; + expect(Option.isNone(yield* Deferred.poll(spawned))).toBe(true); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); + }), + ); }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6c601fad80f3..98deb8228d31 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service< interface ActiveConnector { readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly executable: RelayClient.AvailableRelayClient; readonly scope: Scope.Closeable; readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; @@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; // Ask for recovery after this long, and again at this interval while it stays // unconnected; the relay hands back the same tunnel when it is still live. const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); +// A failed background install of the pinned relay client is retried no sooner +// than this, so a crash-looping connector or an offline host does not redownload +// on every reconcile. +const RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS = 10 * 60_000; + +/** + * A linked host converges on the pinned managed release when it has no relay + * client or runs another managed release. A PATH binary or an explicit override + * is the user's choice, and the CLI asks before downloading, so both stay put. + */ +function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): boolean { + return ( + executable.status === "missing" || + (executable.status === "available" && + executable.source === "managed" && + !RelayClient.isPinnedManagedRelayClient(executable)) + ); +} export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -148,6 +167,11 @@ export const make = Effect.gen(function* () { const reconcileSemaphore = yield* Semaphore.make(1); const restartDelayRef = yield* Ref.make(0); const linkStateSemaphore = yield* Semaphore.make(1); + const runtimeScope = yield* Effect.scope; + const installInFlightRef = yield* Ref.make(false); + const lastInstallFailureAtRef = yield* Ref.make(null); + const prunedRef = yield* Ref.make(false); + const retryLoopRunningRef = yield* Ref.make(false); let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { @@ -228,6 +252,106 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + // Installs the pinned relay client in the background, then restarts the + // connector on it. The running connector, if any, keeps serving until the new + // binary is installed and validated, so an update costs one brief reconnect. + const installPinnedRelayClient = Effect.gen(function* () { + if (yield* Ref.getAndSet(installInFlightRef, true)) return; + yield* Effect.logInfo("Installing the pinned relay client", { + version: RelayClient.CLOUDFLARED_VERSION, + }); + yield* relayClient.install.pipe( + Effect.tap(() => Ref.set(lastInstallFailureAtRef, null)), + Effect.flatMap((installed) => + reconcileSemaphore.withPermits(1)( + Effect.gen(function* () { + const desiredConfig = yield* Ref.get(desiredConfigRef); + if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; + const active = yield* Ref.get(activeRef); + // A self-updated binary in the pinned folder shares the installed path, + // so the version decides whether the running connector is current. + if ( + active?.executable.executablePath === installed.executablePath && + active.executable.version === installed.version + ) { + return; + } + // A fresh binary can fail its probe while a scanner holds it; restarting + // then would land on the same older binary, so leave it to the retry. + const resolved = yield* relayClient.resolve; + if ( + resolved.status !== "available" || + resolved.executablePath !== installed.executablePath || + resolved.version !== installed.version + ) { + return; + } + yield* Effect.logInfo("Relay client installed; restarting the connector on it", { + version: installed.version, + previousVersion: active?.executable.version, + }); + yield* stopActive; + const status = yield* reconcileConfig(desiredConfig); + // The old connector is gone and no supervisor watches a failed spawn, + // so ask for recovery like an exited connector would. + if (status.status === "failed") { + yield* Effect.logWarning("Relay client did not start after the update", status); + yield* Queue.offer(recoveryRequests, desiredConfig); + } + }), + ), + ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((failedAt) => Ref.set(lastInstallFailureAtRef, failedAt)), + Effect.andThen( + Effect.logWarning("Could not install the pinned relay client", { + reason: error.reason, + message: error.message, + cause: error.cause, + }), + ), + ), + }), + Effect.ensuring(Ref.set(installInFlightRef, false)), + ); + }); + + // Reconciles call this; a recent failure skips the attempt so a crash-looping + // connector or an offline host does not redownload on every restart. + const ensurePinnedRelayClient = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const lastFailureAt = yield* Ref.get(lastInstallFailureAtRef); + if (lastFailureAt !== null && now - lastFailureAt < RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS) { + return; + } + yield* installPinnedRelayClient; + }).pipe(Effect.forkIn(runtimeScope), Effect.asVoid); + + // Until the host runs the pinned release, retry the install every interval. + // applyConfig returns early while an older connector is healthy, and some + // activations never retry a missing client, so one runtime loop covers both. + // It paces itself, so it skips the failure gate above. + const retryPinnedRelayClientInstall = Effect.gen(function* () { + while (true) { + yield* Effect.sleep(Duration.millis(RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS)); + const desiredConfig = yield* Ref.get(desiredConfigRef); + if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; + // Stop only once a connector runs on the pin. The pin can be on disk while + // an older connector still runs, or while none runs because a post-install + // probe failed; another install call then finds it and starts the connector. + const active = yield* Ref.get(activeRef); + if (active && !needsPinnedRelayClient(active.executable)) return; + yield* installPinnedRelayClient; + } + }).pipe(Effect.ensuring(Ref.set(retryLoopRunningRef, false))); + + const startPinnedRelayClientRetries = Effect.gen(function* () { + if (yield* Ref.getAndSet(retryLoopRunningRef, true)) return; + yield* Effect.forkIn(retryPinnedRelayClientInstall, runtimeScope); + }); + // Requests recovery while the connector has not registered a connection, // once per timeout, until it connects or is replaced. const watchConnectorRegistration = (connector: ActiveConnector) => @@ -270,6 +394,15 @@ export const make = Effect.gen(function* () { Effect.logInfo("Relay client tunnel connection registered", attributes), ), Effect.andThen(Queue.offer(tunnelConnections, undefined)), + Effect.andThen( + RelayClient.isPinnedManagedRelayClient(connector.executable) + ? Ref.getAndSet(prunedRef, true).pipe( + Effect.flatMap((pruned) => + pruned ? Effect.void : relayClient.pruneManagedVersions, + ), + ) + : Effect.void, + ), Effect.asVoid, ); case "warning": @@ -328,6 +461,10 @@ export const make = Effect.gen(function* () { yield* stopActive; const executable = yield* relayClient.resolve; + if (needsPinnedRelayClient(executable)) { + yield* ensurePinnedRelayClient; + yield* startPinnedRelayClientRetries; + } if (executable.status !== "available") { return { status: "failed", @@ -365,6 +502,8 @@ export const make = Effect.gen(function* () { Effect.tap((child) => Effect.logInfo("Relay client process started; waiting for tunnel connection", { pid: Number(child.pid), + source: executable.source, + version: executable.version, tunnelId: config.tunnelId, tunnelName: config.tunnelName, }), @@ -395,6 +534,7 @@ export const make = Effect.gen(function* () { if (!("status" in child)) { const connector = { child, + executable, scope: connectorScope, configKey: nextConfigKey, config, diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 0ea2c7dae236..4f3e06204164 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its `generation`, and deletion locks the row at the generation it claimed, so a host that reconnects mid-sweep wins. +## The relay client follows the server's pin + +The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in +[`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there +(version, URLs, and checksums) is the whole release step. A linked host that +starts a server with a new pin keeps its connector up on the newest older managed +release, installs the pinned one in the background, then restarts only the +connector child on it. If that restart fails to spawn, the host requests recovery +as it would for an exited connector. A host with no relay client installs one +first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the +user's choice and is never replaced, since linking asks before downloading. + +Once the pinned release registers a connection, managed releases older than the +pin are deleted except the newest of them. That one, and any newer release, can +belong to another server sharing the same T3 home, such as a rollback after a +failed update, which could not run again without it. + +The version comes from running `cloudflared version`, not from the folder name. +Connectors from before `--no-autoupdate` replaced themselves in place, so a +managed folder can hold another release. Such a binary still runs as a fallback +while the pin installs. Every binary needs `CLOUDFLARED_MIN_VERSION`, the oldest +release that accepts every flag the connector is started with. Raise it whenever +a new flag is added. + ## OAuth traps Interactive clients and the headless CLI use the same Clerk application but diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index f4a6b29cb585..85482f876c53 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record = {}) => ConfigProvider.layer(ConfigProvider.fromEnv({ env })), ); -function makeHandle(exitCode = 0) { +function makeHandle(exitCode = 0, output = "") { + const stdout = output ? Stream.make(new TextEncoder().encode(output)) : Stream.empty; return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(100), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), @@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.empty, + stdout, stderr: Stream.empty, - all: Stream.empty, + all: stdout, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); @@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); -const layerSpawner = (commands: Array) => +// Answers `cloudflared version` with the version recorded for that path, which +// defaults to the pinned release; tests change it to simulate other binaries. +const layerSpawner = (commands: Array, versions: Record = {}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.sync(() => { - commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); - // The pinned Windows executable rejects --version but accepts the version subcommand. - return makeHandle( - ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, - ); + if (!ChildProcess.isStandardCommand(command)) { + commands.push("piped-command"); + return makeHandle(); + } + commands.push(command.command); + if (command.args[0] === "version") { + const version = versions[command.command] ?? RelayClient.CLOUDFLARED_VERSION; + return makeHandle(0, `cloudflared version ${version} (built 2026-01-01-00:00 UTC)\n`); + } + return makeHandle(); }), ), ); +const writeExecutable = (filePath: string, contents = "cloudflared") => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(filePath.slice(0, filePath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.writeFileString(filePath, contents); + yield* fileSystem.chmod(filePath, 0o755); + }); + +type RelayClientTestServices = + | Layer.Success | typeof NodeServices.layer> + | HttpClient.HttpClient + | ChildProcessSpawner.ChildProcessSpawner; + +const managedPathFor = (baseDir: string, version: string) => + `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; + describe("RelayClient", () => { it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", @@ -220,7 +246,9 @@ describe("RelayClient", () => { concurrency: "unbounded", }); expect(second).toEqual(first); - expect(commands).toHaveLength(1); + // The first install validates the download once; the second, after waiting + // its turn, probes the activated binary and reuses it instead of downloading. + expect(commands).toHaveLength(2); }).pipe( Effect.scoped, Effect.provide( @@ -278,4 +306,179 @@ describe("RelayClient", () => { ); }, ); + + describe("version selection", () => { + const run = ( + body: (baseDir: string) => Effect.Effect, + options: { readonly versions?: Record } = {}, + ) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + return yield* body(baseDir); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([], options.versions), + layerHostRuntime({ PATH: "" }), + ), + ), + ); + + it.effect.skipIf(windowsHost)("skips a PATH binary older than the minimum version", () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const oldPath = `${baseDir}/old/cloudflared`; + const newPath = `${baseDir}/new/cloudflared`; + yield* writeExecutable(oldPath); + yield* writeExecutable(newPath); + versions[oldPath] = "2023.8.2"; + versions[newPath] = RelayClient.CLOUDFLARED_MIN_VERSION; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const env = { PATH: `${baseDir}/old:${baseDir}/new` }; + expect( + yield* manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env }), + ), + ), + ).toEqual({ + status: "available", + executablePath: newPath, + source: "path", + version: RelayClient.CLOUDFLARED_MIN_VERSION, + }); + }), + { versions }, + ); + }); + + it.effect.skipIf(windowsHost)("reports an outdated override as missing", () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const overridePath = `${baseDir}/override/cloudflared`; + yield* writeExecutable(overridePath); + versions[overridePath] = "2025.6.0"; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const env = { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }; + const withEnv = Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env }), + ); + expect(yield* manager.resolve.pipe(withEnv)).toEqual({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }); + const error = yield* manager.install.pipe(withEnv, Effect.flip); + expect(error.reason).toBe("override_missing"); + }), + { versions }, + ); + }); + + it.effect.skipIf(windowsHost)( + "prefers the pinned release, then the newest older managed release, then PATH", + () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathBinary = `${baseDir}/bin/cloudflared`; + const older = managedPathFor(baseDir, "2025.9.0"); + const newer = managedPathFor(baseDir, "2026.1.0"); + const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); + // A newer server sharing this base dir owns this folder. + const newerServer = managedPathFor(baseDir, "2099.1.0"); + versions[newerServer] = "2099.1.0"; + yield* writeExecutable(newerServer); + versions[pathBinary] = "2026.9.3"; + versions[older] = "2025.9.0"; + versions[newer] = "2026.1.0"; + yield* writeExecutable(pathBinary); + yield* writeExecutable(older); + yield* writeExecutable(newer); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const resolveWith = manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { PATH: `${baseDir}/bin` } }), + ), + ); + + expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); + + yield* writeExecutable(pinned); + expect(yield* resolveWith).toMatchObject({ + executablePath: pinned, + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + }); + + yield* manager.pruneManagedVersions; + expect( + (yield* fileSystem.readDirectory(`${baseDir}/tools/cloudflared`)).sort(), + ).toEqual(["2026.1.0", RelayClient.CLOUDFLARED_VERSION, "2099.1.0"]); + + // The kept older release still outranks PATH; PATH is the last resort. + yield* fileSystem.remove(pinned); + expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); + yield* fileSystem.remove(`${baseDir}/tools/cloudflared/2026.1.0`, { + recursive: true, + }); + expect(yield* resolveWith).toMatchObject({ source: "path", version: "2026.9.3" }); + }), + { versions }, + ); + }, + ); + + it.effect.skipIf(windowsHost)( + "keeps a self-updated managed binary as a fallback but still installs the pin", + () => { + const versions: Record = {}; + return run( + (baseDir) => + Effect.gen(function* () { + const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); + versions[pinned] = "2026.9.3"; + yield* writeExecutable(pinned); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const resolved = yield* manager.resolve; + expect(resolved).toEqual({ + status: "available", + executablePath: pinned, + source: "managed", + version: "2026.9.3", + }); + expect( + resolved.status === "available" && RelayClient.isPinnedManagedRelayClient(resolved), + ).toBe(false); + }), + { versions }, + ); + }, + ); + }); + + it("orders and parses cloudflared versions", () => { + expect( + RelayClient.parseCloudflaredVersionOutput( + "cloudflared version 2026.5.2 (built 2026-05-20-10:00 UTC)", + ), + ).toBe("2026.5.2"); + expect(RelayClient.parseCloudflaredVersionOutput("Incorrect Usage")).toBeNull(); + expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); + expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 57576cad32f3..67dee30bdcd5 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -1,3 +1,4 @@ +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import type { RelayClientInstallProgressEvent, @@ -7,7 +8,9 @@ import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; +// The oldest release that accepts every flag the connector is started with +// (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries +// below it are skipped, since they exit immediately on the unknown flag. +export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", archive: "binary", }, + // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation. + "win32-arm64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", + sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", + archive: "binary", + }, }; const INSTALL_LOCK_RETRY_COUNT = 100; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +const VERSION_PROBE_TIMEOUT = "10 seconds"; const trimmedString = (name: string) => Config.String(name).pipe( @@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions { } export interface RelayClientShape { + /** + * Finds the relay client to run, without downloading anything: the override, + * else the pinned managed folder, else the newest older managed folder, else + * `cloudflared` on PATH. Every candidate must report a compatible version, and + * the status carries the version the binary reports. + */ readonly resolve: Effect.Effect; + /** Installs the pinned managed release unless it, or a valid override, is already present. */ readonly install: Effect.Effect; readonly installWithProgress: ( report: (event: RelayClientInstallProgressEvent) => Effect.Effect, ) => Effect.Effect; + /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */ + readonly pruneManagedVersions: Effect.Effect; +} + +/** True when this is the pinned managed release, which needs no update. */ +export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean { + return client.source === "managed" && client.version === CLOUDFLARED_VERSION; } export class RelayClient extends Context.Service()( @@ -146,6 +174,33 @@ function resolveReleaseAsset( return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; } +const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u; + +function parseVersion(value: string): readonly [number, number, number] | null { + const match = VERSION_PATTERN.exec(value); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +/** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */ +export function compareCloudflaredVersions(left: string, right: string): number { + const a = parseVersion(left) ?? [0, 0, 0]; + const b = parseVersion(right) ?? [0, 0, 0]; + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +/** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */ +export function parseCloudflaredVersionOutput(output: string): string | null { + const version = parseVersion(output); + return version ? version.join(".") : null; +} + +// A changed file is a new key, so a replaced binary is probed again. +class VersionProbeKey extends Data.Class<{ + readonly executablePath: string; + readonly size: number; + readonly mtimeMillis: number; +}> {} + function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } @@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const arch = yield* HostProcessArchitecture; const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); - const managedPath = path.join( - options.baseDir, - "tools", - "cloudflared", - CLOUDFLARED_VERSION, - `${platform}-${arch}`, - executableFileName(platform), - ); + const managedRoot = path.join(options.baseDir, "tools", "cloudflared"); + const managedPathFor = (version: string) => + path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform)); + const managedPath = managedPathFor(CLOUDFLARED_VERSION); const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( executablePath: string, @@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + // `cloudflared version` costs a process spawn, so answers are cached per file + // revision. Before `--no-autoupdate`, a managed binary could replace itself in + // place, so the version is read from the binary, never from its folder name. + // A failed or timed-out probe is not cached, so a slow spawn under an AV scan + // does not hide a good binary until it changes on disk. + const versionCache = yield* Cache.makeWith( + (key: VersionProbeKey) => + spawner + .string( + ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), + ) + .pipe( + Effect.map(parseCloudflaredVersionOutput), + Effect.timeoutOption(VERSION_PROBE_TIMEOUT), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ), + { + capacity: 16, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero, + }, + ); + const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) { + const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return null; + if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null; + return yield* Cache.get( + versionCache, + new VersionProbeKey({ + executablePath, + size: Number(info.value.size), + mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0, + }), + ); + }); + + const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* ( + executablePath: string, + source: RelayClientExecutableSource, + expectedVersion?: string, + ) { + const version = yield* probeVersion(executablePath); + if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) { + return null; + } + if (expectedVersion !== undefined && version !== expectedVersion) { + yield* Effect.logWarning("Ignoring a managed relay client that reports another version", { + executablePath, + expectedVersion, + version, + }); + return null; + } + return { status: "available", executablePath, source, version } satisfies AvailableRelayClient; + }); + + // Managed releases older than the pin, newest first. Newer folders belong to + // a newer server sharing this base dir and are never used or pruned here. + const olderManagedVersions = Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(managedRoot) + .pipe(Effect.orElseSucceed(() => [])); + return entries + .filter( + (entry) => + parseVersion(entry) !== null && + compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0, + ) + .sort((left, right) => compareCloudflaredVersions(right, left)); + }); + const resolvePathExecutable = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; const pathValue = Option.getOrUndefined(config.path); @@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const trimmed = directory.trim().replace(/^"|"$/gu, ""); if (trimmed.length === 0) continue; const candidate = path.join(trimmed, executableFileName(platform)); - if (yield* isExecutableFile(candidate)) return candidate; + if (!(yield* isExecutableFile(candidate))) continue; + const available = yield* compatibleCandidate(candidate, "path"); + if (available) return available; + yield* Effect.logWarning("Skipping an incompatible relay client on PATH", { + executablePath: candidate, + minimumVersion: CLOUDFLARED_MIN_VERSION, + }); } return null; }); + const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION }; + const unsupportedStatus: RelayClientStatus = { + status: "unsupported", + platform, + arch, + version: CLOUDFLARED_VERSION, + }; const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { - return (yield* isExecutableFile(config.executableOverride.value)) - ? { - status: "available", - executablePath: config.executableOverride.value, - source: "override", - version: CLOUDFLARED_VERSION, - } - : { status: "missing", version: CLOUDFLARED_VERSION }; + const override = yield* compatibleCandidate(config.executableOverride.value, "override"); + if (override) return override; + yield* Effect.logWarning( + `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`, + { executablePath: config.executableOverride.value }, + ); + return missingStatus; } - if (yield* isExecutableFile(managedPath)) { - return { - status: "available", - executablePath: managedPath, - source: "managed", - version: CLOUDFLARED_VERSION, - }; + // A managed binary reports the version it actually runs. One that replaced + // itself in place still serves as a fallback; the runtime sees it is not the + // pinned release and installs that. + const pinned = yield* compatibleCandidate(managedPath, "managed"); + if (pinned) return pinned; + for (const version of yield* olderManagedVersions) { + const older = yield* compatibleCandidate(managedPathFor(version), "managed"); + if (older) return older; } const pathExecutable = yield* resolvePathExecutable; - if (pathExecutable) { - return { - status: "available", - executablePath: pathExecutable, - source: "path", - version: CLOUDFLARED_VERSION, - }; + if (pathExecutable) return pathExecutable; + return releaseAsset ? missingStatus : unsupportedStatus; + }).pipe(Effect.withSpan("cloudflared.resolve")); + + const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () { + // The newest older release stays: an older server sharing this base dir, or a + // rollback after a failed update, may still need it and cannot redownload it. + for (const version of (yield* olderManagedVersions).slice(1)) { + // A connector from another server sharing this base dir may still run an + // older release; Windows refuses to delete it, so the next prune retries. + yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe( + Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })), + Effect.catch((cause) => + Effect.logDebug("Could not remove an older managed relay client", { version, cause }), + ), + ); } - return releaseAsset - ? { status: "missing", version: CLOUDFLARED_VERSION } - : { - status: "unsupported", - platform, - arch, - version: CLOUDFLARED_VERSION, - }; - }); + }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); const runCommand = Effect.fn("cloudflared.runCommand")(function* ( command: string, @@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function report: (stage: RelayClientInstallProgressStage) => Effect.Effect, ) { yield* report("checking"); - const existing = yield* resolve; - if (existing.status === "available") return existing; const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { + const override = yield* compatibleCandidate(config.executableOverride.value, "override"); + if (override) return override; return yield* new RelayClientInstallError({ reason: "override_missing", - message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`, }); } + const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); + if (existing) return existing; if (!releaseAsset) { return yield* new RelayClientInstallError({ reason: "unsupported_platform", @@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }), ); return yield* Effect.gen(function* () { - const afterLock = yield* resolve; - if (afterLock.status === "available") return afterLock; + const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); + if (afterLock) return afterLock; const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ directory: managedDirectory, @@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - yield* runCommand(executablePath, ["version"]).pipe( - wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), - ); + // Requiring the pinned version here keeps a mislabelled asset from being + // activated, ignored by resolve, and downloaded again on every reconcile. + const installedVersion = yield* probeVersion(executablePath); + if (installedVersion !== CLOUDFLARED_VERSION) { + return yield* new RelayClientInstallError({ + reason: "validation_failed", + message: + installedVersion === null + ? "The downloaded relay client binary did not run." + : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`, + }); + } const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); @@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); const install = installWithProgress(() => Effect.void); - return RelayClient.of({ resolve, install, installWithProgress }); + return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions }); }); export const layerCloudflared = (options: CloudflaredRelayClientOptions) => From bd565bd1794f49f176ad137555faa46224716138 Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Thu, 8 Oct 2026 16:19:09 -0400 Subject: [PATCH 019/377] fix(shared): relay client install waits out a brief Windows file lock (#16998) Co-authored-by: Claude Opus 5.5 (1M context) --- packages/shared/src/relayClient.test.ts | 157 +++++++++++++++++++++++- packages/shared/src/relayClient.ts | 37 ++++-- 2 files changed, 183 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 85482f876c53..291cd347039b 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; @@ -18,9 +22,9 @@ import * as RelayClient from "./relayClient.ts"; // POSIX exec bits that NTFS never reports; the win32 branch skips that check. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; -const layerHostRuntime = (env: Record = {}) => +const layerHostRuntime = (env: Record = {}, platform: NodeJS.Platform = "linux") => Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessArchitecture, "x64"), ConfigProvider.layer(ConfigProvider.fromEnv({ env })), ); @@ -91,6 +95,64 @@ type RelayClientTestServices = const managedPathFor = (baseDir: string, version: string) => `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; +const renameError = (code: string, path: string) => + PlatformError.systemError({ + _tag: code === "EBUSY" ? "Busy" : code === "EACCES" ? "PermissionDenied" : "Unknown", + module: "FileSystem", + method: "rename", + pathOrDescriptor: path, + cause: Object.assign(new Error(code), { code }), + }); + +type InstallRename = "staging" | "activation"; + +/** + * Fails the first `failures` staging or activation renames with `code`, as Windows does while + * another process holds the file. Staging moves the binary to a `.tmp` name beside its final path. + */ +const makeLockedRenames = ( + fileSystem: FileSystem.FileSystem, + input: { readonly rename: InstallRename; readonly failures: number; readonly code: string }, +) => + Effect.gen(function* () { + const firstAttempt = yield* Deferred.make(); + const renames = { attempts: 0 }; + const locked = FileSystem.make({ + ...fileSystem, + rename: (from, to) => + Effect.suspend(() => { + if ((to.endsWith(".tmp") ? "staging" : "activation") !== input.rename) { + return fileSystem.rename(from, to); + } + renames.attempts += 1; + return renames.attempts <= input.failures + ? Deferred.succeed(firstAttempt, undefined).pipe( + Effect.andThen(Effect.fail(renameError(input.code, to))), + ) + : fileSystem.rename(from, to); + }), + }); + return { locked, renames, firstAttempt }; + }); + +const testBinary = new TextEncoder().encode("test-cloudflared-binary"); +const testReleaseAsset = { + url: "https://example.test/cloudflared", + sha256: Hex.encode(sha256(testBinary)), + archive: "binary", +} as const; + +const layerInstallRuntime = (platform: NodeJS.Platform) => + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(testBinary), + layerSpawner([]), + layerHostRuntime({ PATH: "" }, platform), + ); + +const managedDirectory = (baseDir: string, platform: NodeJS.Platform) => + `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`; + describe("RelayClient", () => { it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", @@ -481,4 +543,95 @@ describe("RelayClient", () => { expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); }); + + it.effect.each([ + ["staging", "EBUSY"], + ["activation", "EACCES"], + ] as const)("retries %s on Windows while another process holds the binary", ([rename, code]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { + rename, + failures: 2, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(firstAttempt); + yield* TestClock.adjust("1 second"); + const installed = yield* Fiber.join(installing); + + expect(renames.attempts).toBe(3); + expect(new TextDecoder().decode(yield* fileSystem.readFile(installed.executablePath))).toBe( + "test-cloudflared-binary", + ); + expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([ + "cloudflared.exe", + ]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), + ); + + it.effect.each([ + ["staging", "EPERM", "Could not stage the relay client."], + ["activation", "EBUSY", "Could not activate the relay client."], + ] as const)("gives up on a Windows %s lock that does not clear", ([rename, code, message]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { + rename, + failures: Infinity, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const installing = yield* manager.install.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(firstAttempt); + yield* TestClock.adjust("1 minute"); + const error = yield* Fiber.join(installing); + + expect(error.message).toBe(message); + expect(renames.attempts).toBe(41); + // Nothing is installed, and the staged copy, download folder, and install lock are gone. + expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), + ); + + it.effect.each([ + ["linux", "EBUSY"], + ["win32", "EXDEV"], + ] as const)("fails staging at once on %s for %s", ([platform, code]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames } = yield* makeLockedRenames(fileSystem, { + rename: "staging", + failures: Infinity, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const error = yield* manager.install.pipe(Effect.flip); + + expect(error.message).toBe("Could not stage the relay client."); + expect(renames.attempts).toBe(1); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))), + ); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 67dee30bdcd5..e75133256ef5 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -116,6 +117,9 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis"; const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; +const ACTIVATE_RETRY_COUNT = 40; +const ACTIVATE_RETRY_DELAY = "250 millis"; + const trimmedString = (name: string) => Config.String(name).pipe( Config.option, @@ -205,6 +209,13 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } +// Windows refuses to rename a file another process briefly holds open, such as a virus +// scanner reading the binary that just ran. These codes clear once it lets go. +function isTransientWindowsLock(error: PlatformError.PlatformError): boolean { + const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; +} + const wrapInstallFailure = ( reason: RelayClientInstallError["reason"], @@ -397,6 +408,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); + /** Moves the new binary toward its final path, waiting out a brief Windows lock. */ + const renameWhenUnlocked = (from: string, to: string) => + fileSystem.rename(from, to).pipe( + Effect.retry({ + times: ACTIVATE_RETRY_COUNT, + schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY), + while: (error) => platform === "win32" && isTransientWindowsLock(error), + }), + ); + const runCommand = Effect.fn("cloudflared.runCommand")(function* ( command: string, args: ReadonlyArray, @@ -574,15 +595,13 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); - yield* fileSystem - .rename(executablePath, stagedPath) - .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); - yield* fileSystem - .rename(stagedPath, managedPath) - .pipe( - wrapInstallFailure("write_failed", "Could not activate the relay client."), - Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), - ); + yield* renameWhenUnlocked(executablePath, stagedPath).pipe( + wrapInstallFailure("write_failed", "Could not stage the relay client."), + ); + yield* renameWhenUnlocked(stagedPath, managedPath).pipe( + wrapInstallFailure("write_failed", "Could not activate the relay client."), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); return { status: "available", executablePath: managedPath, From c377d1ca17dbce42008fbb05fccd95468b13d329 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:52:58 +0530 Subject: [PATCH 020/377] fix(shared): release relay install locks on cancellation (#10585) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/relayClient.test.ts | 133 ++++++++++++++++++++++++ packages/shared/src/relayClient.ts | 8 +- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 291cd347039b..23ae8eb2d33d 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -154,6 +155,133 @@ const managedDirectory = (baseDir: string, platform: NodeJS.Platform) => `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`; describe("RelayClient", () => { + it.effect("cancels a contended install without removing the other installer's lock", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const directory = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64`; + const lockPath = `${directory}/cloudflared.lock`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(lockPath, "other-installer"); + const contended = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFileString: (path, contents, options) => + fileSystem + .writeFileString(path, contents, options) + .pipe( + Effect.tapError(() => + path === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, + ), + ), + }), + ); + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(contended); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.readFileString(lockPath)).toBe("other-installer"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); + + it.effect("releases the install lock when a download is cancelled", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const downloading = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const installing = yield* manager + .installWithProgress((event) => + event.type === "progress" && event.stage === "downloading" + ? Deferred.succeed(downloading, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(downloading); + expect(yield* fileSystem.exists(lockPath)).toBe(true); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); + + it.effect.skipIf(windowsHost)("releases a lock acquired while installation is cancelled", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const acquired = yield* Deferred.make(); + const completeWrite = yield* Deferred.make(); + let pauseWrite = true; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Hex.encode(sha256(new TextEncoder().encode("test-binary"))), + archive: "binary", + }, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFileString: (path, contents, options) => + fileSystem + .writeFileString(path, contents, options) + .pipe( + Effect.tap(() => + path === lockPath && pauseWrite + ? Deferred.succeed(acquired, undefined).pipe( + Effect.andThen(Deferred.await(completeWrite)), + ) + : Effect.void, + ), + ), + }), + ); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(acquired); + const cancelling = yield* Fiber.interrupt(installing).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(completeWrite, undefined); + yield* Fiber.join(cancelling); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + + pauseWrite = false; + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new TextEncoder().encode("test-binary")), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", () => @@ -274,6 +402,11 @@ describe("RelayClient", () => { const error = yield* manager.install.pipe(Effect.flip); expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError); expect(error.reason).toBe("invalid_checksum"); + expect( + yield* fileSystem.exists( + `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`, + ), + ).toBe(false); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index e75133256ef5..9e2f4bea8229 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -487,7 +487,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function lockPath: string, ) { for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { - const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( + const acquired = yield* Effect.acquireRelease( + fileSystem.writeFileString(lockPath, "", { flag: "wx" }), + () => fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore), + ).pipe( Effect.as(true), Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), ); @@ -610,7 +613,6 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } satisfies AvailableRelayClient; }).pipe( Effect.scoped, - Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), Effect.catchIf( (cause) => !(cause instanceof RelayClientInstallError), (cause) => @@ -631,7 +633,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function type: "progress", stage, }), - ), + ).pipe(Effect.scoped), ); const install = installWithProgress(() => Effect.void); From af3d842a001d030ed528396bf59cf02bbf373cd0 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 14:23:37 -0600 Subject: [PATCH 021/377] chore(shared): bump managed cloudflared to 2026.10.0 (#11184) Co-authored-by: Claude Opus 5.5 --- .../cloud/relayClientInstallDialog.test.ts | 24 ++++++++--------- packages/shared/src/relayClient.ts | 26 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 7bd8d4967e41..8ec1820d1115 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -17,24 +17,24 @@ describe("relay client install dialog coordinator", () => { }); it("moves a confirmed installation through streamed progress stages", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); expect(readRelayClientInstallDialogState()).toEqual({ status: "confirming", - version: "2026.5.2", + version: "2026.10.0", }); respondToRelayClientInstallConfirmation(true); await expect(confirmation).resolves.toBe(true); expect(readRelayClientInstallDialogState()).toEqual({ status: "installing", - version: "2026.5.2", + version: "2026.10.0", stage: "checking", }); reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); expect(readRelayClientInstallDialogState()).toEqual({ status: "installing", - version: "2026.5.2", + version: "2026.10.0", stage: "downloading", }); @@ -43,7 +43,7 @@ describe("relay client install dialog coordinator", () => { status: "closing", view: { status: "installing", - version: "2026.5.2", + version: "2026.10.0", stage: "downloading", }, }); @@ -53,7 +53,7 @@ describe("relay client install dialog coordinator", () => { }); it("returns to idle when installation is declined", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); respondToRelayClientInstallConfirmation(false); await expect(confirmation).resolves.toBe(false); @@ -61,7 +61,7 @@ describe("relay client install dialog coordinator", () => { status: "closing", view: { status: "confirming", - version: "2026.5.2", + version: "2026.10.0", }, }); @@ -70,26 +70,26 @@ describe("relay client install dialog coordinator", () => { }); it("rejects concurrent confirmation with the active install state", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); respondToRelayClientInstallConfirmation(true); await expect(confirmation).resolves.toBe(true); reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); - const error = await requestRelayClientInstallConfirmation("2026.6.0").then( + const error = await requestRelayClientInstallConfirmation("2026.11.0").then( () => undefined, (cause: unknown) => cause, ); expect(error).toBeInstanceOf(RelayClientInstallConfirmationConflictError); expect(error).toMatchObject({ - requestedVersion: "2026.6.0", - activeVersion: "2026.5.2", + requestedVersion: "2026.11.0", + activeVersion: "2026.10.0", activeDialogStatus: "installing", activeInstallStage: "downloading", }); expect(error).not.toHaveProperty("cause"); expect((error as Error).message).toBe( - "Cannot confirm relay client installation 2026.6.0; installation 2026.5.2 has dialog status installing.", + "Cannot confirm relay client installation 2026.11.0; installation 2026.10.0 has dialog status installing.", ); }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 9e2f4bea8229..994c735710da 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -23,7 +23,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; -export const CLOUDFLARED_VERSION = "2026.5.2"; +export const CLOUDFLARED_VERSION = "2026.10.0"; // The oldest release that accepts every flag the connector is started with // (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries // below it are skipped, since they exit immediately on the unknown flag. @@ -80,34 +80,34 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< Partial> > = { "darwin-arm64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-arm64.tgz", - sha256: "ba94054c9fd4297645093d59d51442e5e546d07bb0516120e694a13d5b216d38", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-darwin-arm64.tgz", + sha256: "a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70", archive: "tgz", }, "darwin-x64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-amd64.tgz", - sha256: "7240f709506bc2c1eb9da4d89cf2555499c60280ecb854b7d80e8f17d4b7903d", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-darwin-amd64.tgz", + sha256: "903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f", archive: "tgz", }, "linux-arm64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-arm64", - sha256: "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-linux-arm64", + sha256: "e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08", archive: "binary", }, "linux-x64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-amd64", - sha256: "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-linux-amd64", + sha256: "d33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db", archive: "binary", }, "win32-x64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", - sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-windows-amd64.exe", + sha256: "86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c", archive: "binary", }, // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation. "win32-arm64": { - url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", - sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.10.0/cloudflared-windows-amd64.exe", + sha256: "86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c", archive: "binary", }, }; From 580948708b66f2a971199670f76cb6b43119e9a4 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:31:53 -0500 Subject: [PATCH 022/377] fix(shared): bound cloudflared download with 10-minute timeout (#14139) Co-authored-by: Kevin Rajan --- packages/shared/src/relayClient.test.ts | 51 ++++++++++++++++++++++++- packages/shared/src/relayClient.ts | 7 ++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 23ae8eb2d33d..a96f3f8d864b 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; @@ -57,6 +56,16 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); +// Records each request and never responds, simulating a wedged endpoint. +const layerStalledHttpClient = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.never; + }), + ); + // Answers `cloudflared version` with the version recorded for that path, which // defaults to the pinned release; tests change it to simulate other binaries. const layerSpawner = (commands: Array, versions: Record = {}) => @@ -767,4 +776,44 @@ describe("RelayClient", () => { expect(renames.attempts).toBe(1); }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))), ); + + it.effect("fails a stalled download after the download timeout", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: "00".repeat(32), + archive: "binary", + }, + }); + + const child = yield* Effect.forkChild(manager.install); + // Spin until the wedged download is in flight, so the clock + // adjustment below cannot run before the timeout is armed. + while (requests.length === 0) { + yield* Effect.yieldNow; + } + // The download timeout is 10 minutes; advance past it. + yield* TestClock.adjust("11 minutes"); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError); + expect(error.reason).toBe("download_failed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerStalledHttpClient(requests), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 994c735710da..3705e7677cdd 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -30,6 +30,11 @@ export const CLOUDFLARED_VERSION = "2026.10.0"; export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; +// Generous bound for a ~40MB binary download; without it a stalled +// connection parks the install forever (same unbounded-wait family as the +// other relay/cloud network calls). +const CLOUDFLARED_DOWNLOAD_TIMEOUT = "10 minutes"; + export type RelayClientExecutableSource = "override" | "managed" | "path"; export type RelayClientStatus = @@ -441,6 +446,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ) { yield* report("downloading"); const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( + Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError( (cause) => @@ -453,6 +459,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); const bytes = new Uint8Array( yield* response.arrayBuffer.pipe( + Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT), Effect.mapError( (cause) => new RelayClientInstallError({ From 9eea28bacd27b2ee6d5183e195c397bba8b515a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 14:18:16 -0700 Subject: [PATCH 023/377] refactor(provider-core): add provider-core and provider-testing packages (#17299) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 2 + apps/server/scripts/evaluate-thread-titles.ts | 2 +- ...probe-claude-fork-local-rollback-replay.ts | 4 +- .../record-claude-agent-sdk-replay-fixture.ts | 4 +- .../record-cursor-agent-sdk-replay-fixture.ts | 2 +- .../scripts/record-grok-acp-replay-fixture.ts | 6 +- .../scripts/record-muse-msp-replay-fixture.ts | 6 +- .../scripts/record-pi-rpc-replay-fixture.ts | 4 +- .../scripts/threadTitleEvaluationCases.ts | 2 +- apps/server/src/assets/AssetAccess.ts | 2 +- apps/server/src/mcp/McpSessionRegistry.ts | 2 +- .../server/src/mcp/McpToolAccess.race.test.ts | 2 +- .../src/mcp/OrchestratorMcpService.test.ts | 2 +- ...OrchestratorMcpToolkit.integration.test.ts | 6 +- .../server/src/mcpApps/McpAppRequests.test.ts | 2 +- apps/server/src/mcpApps/McpAppRequests.ts | 2 +- .../Adapters/AcpAdapterV2.test.ts | 6 +- .../Adapters/AcpAdapterV2.testkit.ts | 2 +- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 17 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 4 +- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 4 +- .../Adapters/AcpRegistryAdapterV2.ts | 8 +- .../Adapters/AntigravityAdapterV2.test.ts | 4 +- .../Adapters/AntigravityAdapterV2.ts | 2 +- .../Adapters/ClaudeAdapterV2.test.ts | 8 +- .../Adapters/ClaudeAdapterV2.testkit.ts | 8 +- .../Adapters/ClaudeAdapterV2.ts | 28 ++-- .../Adapters/CodexAdapterV2.test.ts | 8 +- .../Adapters/CodexAdapterV2.testkit.ts | 8 +- .../Adapters/CodexAdapterV2.ts | 18 +- .../Adapters/CursorAdapterV2.test.ts | 6 +- .../Adapters/CursorAdapterV2.testkit.ts | 6 +- .../Adapters/CursorAdapterV2.ts | 18 +- .../Adapters/GrokAdapterV2.test.ts | 6 +- .../Adapters/GrokAdapterV2.testkit.ts | 4 +- .../Adapters/GrokAdapterV2.ts | 10 +- .../Adapters/MuseAdapterV2.test.ts | 6 +- .../Adapters/MuseAdapterV2.testkit.ts | 2 +- .../Adapters/MuseAdapterV2.ts | 10 +- .../Adapters/OpenCode2AdapterV2.test.ts | 6 +- .../Adapters/OpenCode2AdapterV2.testkit.ts | 4 +- .../Adapters/OpenCode2AdapterV2.ts | 12 +- .../Adapters/OpenCodeAdapterV2.test.ts | 4 +- .../Adapters/OpenCodeAdapterV2.testkit.ts | 4 +- .../Adapters/OpenCodeAdapterV2.ts | 18 +- .../Adapters/PiAdapterV2.test.ts | 23 +-- .../Adapters/PiAdapterV2.testkit.ts | 23 ++- .../orchestration-v2/Adapters/PiAdapterV2.ts | 43 +++-- .../src/orchestration-v2/Adapters/PiRpc.ts | 2 +- .../Adapters/piT3McpExtensionSource.ts | 2 +- .../Adapters/piT3McpInjection.ts | 2 +- .../BackgroundWorkStop.integration.test.ts | 4 +- .../CheckpointCaptureService.test.ts | 2 +- .../CheckpointCaptureService.ts | 2 +- .../CheckpointRestoreSafety.test.ts | 2 +- .../CheckpointRollbackService.test.ts | 4 +- .../CheckpointRollbackService.ts | 4 +- .../CheckpointScopeOwnership.test.ts | 2 +- .../CheckpointService.test.ts | 2 +- .../src/orchestration-v2/CheckpointService.ts | 2 +- ...laudeAutomaticDelivery.integration.test.ts | 4 +- .../ContextHandoffBudget.test.ts | 2 +- .../ContextHandoffDelivery.ts | 11 +- .../ContextHandoffService.test.ts | 2 +- .../orchestration-v2/ContextHandoffService.ts | 2 +- .../DelegatedCompletionDelivery.test.ts | 4 +- .../DispatchModeLimit.test.ts | 2 +- .../FoundationPersistence.test.ts | 2 +- ...penCode2OrchestratorV2.integration.test.ts | 6 +- .../OpenCode2OrchestratorV2.live.test.ts | 2 +- .../Orchestrator.control-reads.test.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 6 +- .../ProviderAdapterRegistry.test.ts | 9 +- .../ProviderAdapterRegistry.ts | 4 +- .../ProviderContinuationService.test.ts | 2 +- .../ProviderContinuationService.ts | 2 +- .../ProviderEventIngestor.test.ts | 4 +- .../orchestration-v2/ProviderEventIngestor.ts | 6 +- ...ProviderRuntimeRecoveryPerformance.test.ts | 2 +- ...rRuntimeRecoveryService.regression.test.ts | 2 +- .../ProviderRuntimeRecoveryService.test.ts | 2 +- .../ProviderRuntimeRecoveryService.ts | 2 +- .../ProviderSelectionTransition.test.ts | 2 +- .../ProviderSessionManager.test.ts | 6 +- .../ProviderSessionManager.ts | 6 +- .../ProviderSessionTransitionPolicy.ts | 4 +- .../ProviderSwitchService.test.ts | 4 +- .../ProviderTurnControlService.test.ts | 2 +- .../ProviderTurnStartService.test.ts | 4 +- .../ProviderTurnStartService.ts | 6 +- .../RestartContinuation.test.ts | 2 +- .../RunExecutionService.test.ts | 4 +- .../orchestration-v2/RunExecutionService.ts | 11 +- .../orchestration-v2/RuntimePolicy.test.ts | 2 +- .../src/orchestration-v2/RuntimePolicy.ts | 2 +- .../RuntimeRequestService.test.ts | 2 +- .../SelectionRestart.integration.test.ts | 4 +- .../SteeringCompletion.integration.test.ts | 4 +- .../orchestration-v2/ThreadDeletion.test.ts | 2 +- .../src/orchestration-v2/ThreadDeletion.ts | 2 +- .../ThreadFork.execution.test.ts | 2 +- .../ThreadLaunchService.test.ts | 4 +- .../orchestration-v2/ThreadLaunchService.ts | 6 +- .../src/orchestration-v2/ThreadStop.test.ts | 2 +- .../ThreadTitleRegenerationService.test.ts | 2 +- .../ThreadTitleRegenerationService.ts | 4 +- .../assistantStreaming.test.ts | 2 +- .../orchestration-v2/assistantStreaming.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- .../LegacyV1Cutover.integration.test.ts | 4 +- .../legacy/LegacyV1ThreadImporter.ts | 2 +- .../src/orchestration-v2/runtimeLayer.test.ts | 7 +- .../src/orchestration-v2/runtimeLayer.ts | 2 +- .../ClaudeReplayFixtures.integration.test.ts | 6 +- .../CodexReplayFixtures.integration.test.ts | 2 +- ...rchestratorReplayFixtures.contract.test.ts | 4 +- ...estratorReplayFixtures.integration.test.ts | 8 +- ...estratorReplayRecovery.integration.test.ts | 8 +- ...yRestartBackgroundNote.integration.test.ts | 6 +- .../testkit/OrchestratorScenario.ts | 2 +- .../testkit/ProviderReplayHarness.ts | 7 +- .../ProviderSwitch.integration.test.ts | 6 +- .../ProviderTurnStartMemory.fixture.mjs | 4 +- .../testkit/ReplayRuntimeInstructions.ts | 70 ++++++++ .../testkit/ThreadFork.integration.test.ts | 4 +- .../ThreadMergeBack.integration.test.ts | 6 +- .../testkit/fixtures/shared.ts | 2 +- apps/server/src/processRunner.ts | 25 +-- .../src/project/AgentSessionImporter.test.ts | 2 +- .../src/project/AgentSessionImporter.ts | 2 +- .../server/src/project/AgentSessionScanner.ts | 2 +- .../project/ProjectService.deletion.test.ts | 2 +- .../server/src/project/ProjectService.test.ts | 2 +- apps/server/src/project/ProjectService.ts | 2 +- .../src/provider/AntigravityProvider.test.ts | 20 ++- .../src/provider/AntigravityProvider.ts | 4 +- .../provider/ClaudeCapabilitiesProbe.test.ts | 2 +- apps/server/src/provider/ClaudeProvider.ts | 6 +- .../provider/CodexDeveloperInstructions.ts | 2 +- apps/server/src/provider/CodexProvider.ts | 6 +- .../src/provider/CodexToolPresentation.ts | 2 +- apps/server/src/provider/CursorProvider.ts | 2 +- .../src/provider/Drivers/AcpRegistryDriver.ts | 20 +-- .../Drivers/AntigravityDriver.test.ts | 6 +- .../src/provider/Drivers/AntigravityDriver.ts | 18 +- .../src/provider/Drivers/ClaudeDriver.ts | 20 +-- .../server/src/provider/Drivers/ClaudeHome.ts | 2 +- .../src/provider/Drivers/ClaudeSkills.ts | 2 +- .../src/provider/Drivers/CodexDriver.test.ts | 10 +- .../src/provider/Drivers/CodexDriver.ts | 18 +- .../src/provider/Drivers/CodexHomeLayout.ts | 2 +- .../provider/Drivers/CodexManagedProvider.ts | 15 +- .../src/provider/Drivers/CursorDriver.test.ts | 8 +- .../src/provider/Drivers/CursorDriver.ts | 13 +- .../src/provider/Drivers/GrokDriver.test.ts | 6 +- .../server/src/provider/Drivers/GrokDriver.ts | 18 +- .../server/src/provider/Drivers/GrokSkills.ts | 2 +- .../server/src/provider/Drivers/MuseDriver.ts | 22 ++- .../provider/Drivers/OpenCodeDriver.test.ts | 11 +- .../src/provider/Drivers/OpenCodeDriver.ts | 20 +-- .../src/provider/Drivers/PiDriver.test.ts | 7 +- apps/server/src/provider/Drivers/PiDriver.ts | 24 ++- .../src/provider/Drivers/instanceIdentity.ts | 4 +- apps/server/src/provider/Errors.ts | 19 +-- apps/server/src/provider/GrokProvider.ts | 4 +- apps/server/src/provider/ModelManifest.ts | 2 +- apps/server/src/provider/MuseProvider.test.ts | 2 +- apps/server/src/provider/MuseProvider.ts | 2 +- apps/server/src/provider/OpenCodeProvider.ts | 2 +- .../src/provider/OpenCodeServerLedger.ts | 2 +- apps/server/src/provider/PiProvider.ts | 4 +- .../src/provider/ProviderAuthService.test.ts | 2 +- .../src/provider/ProviderAuthService.ts | 48 +----- apps/server/src/provider/ProviderHostLive.ts | 39 +++++ .../provider/ProviderInstanceRegistry.test.ts | 9 +- .../src/provider/ProviderInstanceRegistry.ts | 2 +- .../ProviderInstanceRegistryHydration.ts | 11 +- ...viderOrchestrationAdapterInfrastructure.ts | 2 +- .../src/provider/ProviderRegistry.test.ts | 9 +- apps/server/src/provider/ProviderRegistry.ts | 7 +- .../src/provider/acp/AcpRegistrySupport.ts | 2 +- .../src/provider/acp/AcpSessionRuntime.ts | 2 +- .../src/provider/antigravityAuthSupport.ts | 2 +- apps/server/src/provider/builtInDrivers.ts | 2 +- .../src/provider/builtInProviderCatalog.ts | 2 +- apps/server/src/provider/claudeUsageLimits.ts | 2 +- apps/server/src/provider/codexUsageLimits.ts | 2 +- apps/server/src/provider/cursorUsageLimits.ts | 2 +- apps/server/src/provider/grokUsageLimits.ts | 2 +- .../makeManagedServerProvider.test.ts | 29 +++- .../src/provider/makeManagedServerProvider.ts | 27 ++- .../src/provider/museMaintenance.test.ts | 2 +- apps/server/src/provider/museMaintenance.ts | 4 +- .../src/provider/openCodeUsageLimits.ts | 2 +- apps/server/src/provider/opencodeRuntime.ts | 4 +- .../src/provider/opencodeVersionProbe.ts | 2 +- .../provider/providerCompatibility.test.ts | 4 +- .../src/provider/providerInstallation.test.ts | 2 +- .../src/provider/providerInstallation.ts | 2 +- .../providerMaintenanceRunner.test.ts | 2 +- .../src/provider/providerMaintenanceRunner.ts | 6 +- .../src/provider/providerUpdateSettings.ts | 8 +- .../testUtils/providerRegistryMock.ts | 2 +- .../provider/unavailableProviderSnapshot.ts | 2 +- apps/server/src/sourceControl/BitbucketApi.ts | 2 +- apps/server/src/sourceControl/ForgejoCli.ts | 2 +- apps/server/src/sourceControl/GitHubApi.ts | 2 +- .../SourceControlRepositoryService.ts | 2 +- apps/server/src/terminal/Manager.ts | 4 +- .../AntigravityTextGeneration.ts | 2 +- .../ClaudeTextGeneration.test.ts | 2 +- .../textGeneration/ClaudeTextGeneration.ts | 7 +- .../src/textGeneration/CodexTextGeneration.ts | 9 +- .../textGeneration/CursorTextGeneration.ts | 2 +- .../src/textGeneration/GrokTextGeneration.ts | 2 +- .../src/textGeneration/MuseTextGeneration.ts | 4 +- .../textGeneration/OpenCode2TextGeneration.ts | 2 +- .../textGeneration/OpenCodeTextGeneration.ts | 2 +- .../src/textGeneration/PiTextGeneration.ts | 2 +- .../src/textGeneration/TextGeneration.test.ts | 4 +- .../src/textGeneration/TextGeneration.ts | 119 ++----------- .../TextGenerationOperations.ts | 153 ----------------- .../textGeneration/TextGenerationPresets.ts | 2 +- apps/server/src/usage/UsageService.ts | 4 +- apps/server/src/usage/cliproxyApi.ts | 2 +- apps/server/src/workspace/WorkspaceEntries.ts | 2 +- apps/server/src/workspace/WorkspacePaths.ts | 2 +- apps/server/src/worktreesDirectory.ts | 2 +- apps/server/src/ws.ts | 4 +- apps/web/package.json | 1 + .../components/chat/ComposerUsageLimits.tsx | 4 +- .../onboarding/WelcomeWizard.import.test.tsx | 4 +- .../WelcomeWizard.terminal.test.tsx | 2 +- .../components/onboarding/WelcomeWizard.tsx | 4 +- .../settings/AddProviderInstanceDialog.tsx | 20 +-- .../settings/ProviderInstanceCard.tsx | 19 ++- .../settings/ProviderSettingsForm.test.ts | 18 +- .../settings/ProviderSettingsForm.tsx | 8 +- .../settings/ProviderSettingsPanel.tsx | 8 +- .../components/settings/providerDriverMeta.ts | 80 ++------- .../components/usage/UsageLimitsPooled.tsx | 12 +- .../usage/UsagePage.refresh.test.tsx | 4 +- docs/internals/adding-a-provider.md | 2 +- docs/internals/providers.md | 2 +- package.json | 2 +- packages/provider-core/package.json | 138 +++++++++++++++ packages/provider-core/src/client.test.ts | 34 ++++ packages/provider-core/src/client.ts | 77 +++++++++ .../provider-core/src/server}/IdAllocator.ts | 4 +- .../src/server}/ProviderAdapter.ts | 4 +- .../provider-core/src/server/ProviderHost.ts | 54 ++++++ .../provider-core/src/server/adapterDriver.ts | 2 +- packages/provider-core/src/server/auth.ts | 57 +++++++ .../src/server/collectStreamText.test.ts | 2 +- .../src/server/collectStreamText.ts | 0 .../provider-core/src/server/driver.ts | 14 +- packages/provider-core/src/server/errors.ts | 21 +++ .../provider-core/src/server/failure.test.ts | 6 +- .../provider-core/src/server/failure.ts | 12 +- .../src/server/instanceEnvironment.test.ts | 2 +- .../src/server/instanceEnvironment.ts | 2 +- .../provider-core/src/server/maintenance.ts | 30 ++++ .../src/server/maintenanceResolver.test.ts | 2 +- .../src/server/maintenanceResolver.ts | 39 +---- .../src/server/mcpSession.test.ts | 2 +- .../provider-core/src/server/mcpSession.ts | 0 .../src/server/mcpToolPresentation.test.ts | 2 +- .../src/server/mcpToolPresentation.ts | 0 .../server/orchestrationInstructions.test.ts | 2 +- .../src/server/orchestrationInstructions.ts | 0 .../src/server}/pathExpansion.test.ts | 0 .../src/server}/pathExpansion.ts | 0 .../src/server}/processGroup.test.ts | 0 .../provider-core/src/server}/processGroup.ts | 0 .../provider-core/src/server/randomUuid.ts | 0 .../src/server/selectionTransition.ts | 0 .../provider-core/src/server/snapshot.ts | 2 +- .../src/server/snapshotProbe.test.ts | 2 +- .../provider-core/src/server/snapshotProbe.ts | 27 ++- .../src/server/textGeneration.ts | 104 ++++++++++++ .../src/server/textGenerationOperations.ts | 157 ++++++++++++++++++ .../src/server/textGenerationPolicy.ts | 0 .../src/server/textGenerationPrompts.test.ts | 4 +- .../src/server/textGenerationPrompts.ts | 6 +- .../src/server/textGenerationUtils.ts | 0 .../src/server/threadTitleContext.test.ts | 2 +- .../src/server/threadTitleContext.ts | 0 .../src/server/usageLimits.test.ts | 2 +- .../provider-core/src/server/usageLimits.ts | 0 packages/provider-core/tsconfig.json | 7 + packages/provider-testing/package.json | 38 +++++ packages/provider-testing/src/host.ts | 54 ++++++ .../provider-testing/src/replayGate.test.ts | 38 +++++ .../provider-testing/src/replayGate.ts | 0 .../src/replayTranscript.test.ts | 96 +++++++++++ .../provider-testing/src/replayTranscript.ts | 70 -------- .../provider-testing/src/replayWorkspace.ts | 0 packages/provider-testing/tsconfig.json | 7 + pnpm-lock.yaml | 59 +++++++ scripts/release-smoke.ts | 2 + 300 files changed, 1893 insertions(+), 1166 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts create mode 100644 apps/server/src/provider/ProviderHostLive.ts delete mode 100644 apps/server/src/textGeneration/TextGenerationOperations.ts create mode 100644 packages/provider-core/package.json create mode 100644 packages/provider-core/src/client.test.ts create mode 100644 packages/provider-core/src/client.ts rename {apps/server/src/orchestration-v2 => packages/provider-core/src/server}/IdAllocator.ts (99%) rename {apps/server/src/orchestration-v2 => packages/provider-core/src/server}/ProviderAdapter.ts (99%) create mode 100644 packages/provider-core/src/server/ProviderHost.ts rename apps/server/src/orchestration-v2/ProviderAdapterDriver.ts => packages/provider-core/src/server/adapterDriver.ts (94%) create mode 100644 packages/provider-core/src/server/auth.ts rename apps/server/src/stream/collectUint8StreamText.test.ts => packages/provider-core/src/server/collectStreamText.test.ts (96%) rename apps/server/src/stream/collectUint8StreamText.ts => packages/provider-core/src/server/collectStreamText.ts (100%) rename apps/server/src/provider/ProviderDriver.ts => packages/provider-core/src/server/driver.ts (95%) create mode 100644 packages/provider-core/src/server/errors.ts rename apps/server/src/orchestration-v2/ProviderFailure.test.ts => packages/provider-core/src/server/failure.test.ts (97%) rename apps/server/src/orchestration-v2/ProviderFailure.ts => packages/provider-core/src/server/failure.ts (94%) rename apps/server/src/provider/ProviderInstanceEnvironment.test.ts => packages/provider-core/src/server/instanceEnvironment.test.ts (96%) rename apps/server/src/provider/ProviderInstanceEnvironment.ts => packages/provider-core/src/server/instanceEnvironment.ts (92%) create mode 100644 packages/provider-core/src/server/maintenance.ts rename apps/server/src/provider/providerMaintenance.test.ts => packages/provider-core/src/server/maintenanceResolver.test.ts (99%) rename apps/server/src/provider/providerMaintenance.ts => packages/provider-core/src/server/maintenanceResolver.ts (96%) rename apps/server/src/mcp/McpProviderSession.test.ts => packages/provider-core/src/server/mcpSession.test.ts (94%) rename apps/server/src/mcp/McpProviderSession.ts => packages/provider-core/src/server/mcpSession.ts (100%) rename apps/server/src/provider/McpToolPresentation.test.ts => packages/provider-core/src/server/mcpToolPresentation.test.ts (98%) rename apps/server/src/provider/McpToolPresentation.ts => packages/provider-core/src/server/mcpToolPresentation.ts (100%) rename apps/server/src/provider/T3OrchestrationInstructions.test.ts => packages/provider-core/src/server/orchestrationInstructions.test.ts (98%) rename apps/server/src/provider/T3OrchestrationInstructions.ts => packages/provider-core/src/server/orchestrationInstructions.ts (100%) rename {apps/server/src => packages/provider-core/src/server}/pathExpansion.test.ts (100%) rename {apps/server/src => packages/provider-core/src/server}/pathExpansion.ts (100%) rename {apps/server/src/process => packages/provider-core/src/server}/processGroup.test.ts (100%) rename {apps/server/src/process => packages/provider-core/src/server}/processGroup.ts (100%) rename apps/server/src/orchestration-v2/RandomUuid.ts => packages/provider-core/src/server/randomUuid.ts (100%) rename apps/server/src/orchestration-v2/ProviderSelectionTransition.ts => packages/provider-core/src/server/selectionTransition.ts (100%) rename apps/server/src/provider/ServerProvider.ts => packages/provider-core/src/server/snapshot.ts (92%) rename apps/server/src/provider/providerSnapshot.test.ts => packages/provider-core/src/server/snapshotProbe.test.ts (99%) rename apps/server/src/provider/providerSnapshot.ts => packages/provider-core/src/server/snapshotProbe.ts (89%) create mode 100644 packages/provider-core/src/server/textGeneration.ts create mode 100644 packages/provider-core/src/server/textGenerationOperations.ts rename apps/server/src/textGeneration/TextGenerationPolicy.ts => packages/provider-core/src/server/textGenerationPolicy.ts (100%) rename apps/server/src/textGeneration/TextGenerationPrompts.test.ts => packages/provider-core/src/server/textGenerationPrompts.test.ts (99%) rename apps/server/src/textGeneration/TextGenerationPrompts.ts => packages/provider-core/src/server/textGenerationPrompts.ts (98%) rename apps/server/src/textGeneration/TextGenerationUtils.ts => packages/provider-core/src/server/textGenerationUtils.ts (100%) rename apps/server/src/textGeneration/ThreadTitleContext.test.ts => packages/provider-core/src/server/threadTitleContext.test.ts (97%) rename apps/server/src/textGeneration/ThreadTitleContext.ts => packages/provider-core/src/server/threadTitleContext.ts (100%) rename apps/server/src/provider/providerUsageLimits.test.ts => packages/provider-core/src/server/usageLimits.test.ts (98%) rename apps/server/src/provider/providerUsageLimits.ts => packages/provider-core/src/server/usageLimits.ts (100%) create mode 100644 packages/provider-core/tsconfig.json create mode 100644 packages/provider-testing/package.json create mode 100644 packages/provider-testing/src/host.ts create mode 100644 packages/provider-testing/src/replayGate.test.ts rename apps/server/src/orchestration-v2/testkit/ProviderReplayGate.testkit.ts => packages/provider-testing/src/replayGate.ts (100%) create mode 100644 packages/provider-testing/src/replayTranscript.test.ts rename apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts => packages/provider-testing/src/replayTranscript.ts (70%) rename apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts => packages/provider-testing/src/replayWorkspace.ts (100%) create mode 100644 packages/provider-testing/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 5f81d57f588f..74bb7cac0e94 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,6 +41,7 @@ "@opencode/client": "2.0.23", "@opencode/protocol": "2.0.23", "@opencode/schema": "2.0.23", + "@t3tools/provider-core": "workspace:*", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", @@ -56,6 +57,7 @@ "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@t3tools/contracts": "workspace:*", + "@t3tools/provider-testing": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index b91196f28f84..0747825dcb5b 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -20,7 +20,7 @@ import { threadTitleEvaluationCases } from "./threadTitleEvaluationCases.ts"; import { formatThreadTitleContext, type ThreadTitleMessage, -} from "../src/textGeneration/ThreadTitleContext.ts"; +} from "@t3tools/provider-core/server/threadTitleContext"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; diff --git a/apps/server/scripts/probe-claude-fork-local-rollback-replay.ts b/apps/server/scripts/probe-claude-fork-local-rollback-replay.ts index e5a233dcc5c8..3d856fb9672e 100644 --- a/apps/server/scripts/probe-claude-fork-local-rollback-replay.ts +++ b/apps/server/scripts/probe-claude-fork-local-rollback-replay.ts @@ -15,8 +15,8 @@ import { makeClaudeUserMessage, type ClaudeAgentSdkQueryOptions, } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import { randomUuidV4 } from "../src/orchestration-v2/RandomUuid.ts"; -import { makeCheckpointWorkspace } from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const SCENARIO = "thread_fork_native_fork_local_rollback"; const DEFAULT_OUTPUT = new URL( diff --git a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts index 22a6df8eefbe..970c1faf1e14 100644 --- a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts +++ b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts @@ -13,9 +13,9 @@ import { claudeRuntimeQueryPolicyForRuntimePolicy } from "../src/orchestration-v import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, -} from "../src/orchestration-v2/ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../src/orchestration-v2/RuntimePolicy.ts"; -import { makeCheckpointWorkspace } from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; import { MESSAGE_STEERING_INITIAL_PROMPT, diff --git a/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts b/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts index 1ccfb13c1bc5..0f50f11ba345 100644 --- a/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts +++ b/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts @@ -15,7 +15,7 @@ import { recordCursorAgentSdkReplayTranscript, type CursorAgentSdkReplayTranscript, } from "../src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts"; -import { checkpointWorkspace } from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { CURSOR_MODEL_SELECTION, MESSAGE_STEERING_INITIAL_PROMPT, diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index cd384c0db7c0..5623b45eda8c 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -32,8 +32,8 @@ import { makeGrokAdapterV2, } from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; -import * as IdAllocator from "../src/orchestration-v2/IdAllocator.ts"; -import type { ProviderAdapterV2SessionRuntime } from "../src/orchestration-v2/ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "../src/orchestration-v2/ProviderContinuationRequests.ts"; import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; @@ -42,7 +42,7 @@ import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixture import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; import { buildRuntimeInstructions } from "../src/provider/RuntimeInstructions.ts"; diff --git a/apps/server/scripts/record-muse-msp-replay-fixture.ts b/apps/server/scripts/record-muse-msp-replay-fixture.ts index 53572e50837d..dde469487be0 100644 --- a/apps/server/scripts/record-muse-msp-replay-fixture.ts +++ b/apps/server/scripts/record-muse-msp-replay-fixture.ts @@ -32,18 +32,18 @@ import { museRecordLabel, MuseOrchestratorReplayHarness, } from "../src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts"; -import * as IdAllocator from "../src/orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { parseMuseVersion } from "../src/provider/museMaintenance.ts"; import { makeMuseEnvironment, museServeArgs } from "../src/provider/museSdk.ts"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; -import { materializeReplayTranscriptWorkspace } from "../src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; +import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; import { checkpointWorkspace, makeCheckpointWorkspace, -} from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +} from "@t3tools/provider-testing/replayWorkspace"; const CLOCK_TICK = Duration.millis(20); diff --git a/apps/server/scripts/record-pi-rpc-replay-fixture.ts b/apps/server/scripts/record-pi-rpc-replay-fixture.ts index 32d0dc7c7560..8ea04f8ada62 100644 --- a/apps/server/scripts/record-pi-rpc-replay-fixture.ts +++ b/apps/server/scripts/record-pi-rpc-replay-fixture.ts @@ -30,7 +30,7 @@ import { } from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; import * as PiAdapterV2Testkit from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; import { PI_PROVIDER } from "../src/orchestration-v2/Adapters/PiAdapterV2.ts"; -import * as IdAllocator from "../src/orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; @@ -38,7 +38,7 @@ import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2 import { checkpointWorkspace, makeCheckpointWorkspace, -} from "../src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +} from "@t3tools/provider-testing/replayWorkspace"; /** * Keeps the user's skills, templates and context files out of the recording. diff --git a/apps/server/scripts/threadTitleEvaluationCases.ts b/apps/server/scripts/threadTitleEvaluationCases.ts index 02d8fecbe4a0..c8c4322d5447 100644 --- a/apps/server/scripts/threadTitleEvaluationCases.ts +++ b/apps/server/scripts/threadTitleEvaluationCases.ts @@ -1,4 +1,4 @@ -import type { ThreadTitleMessage } from "../src/textGeneration/ThreadTitleContext.ts"; +import type { ThreadTitleMessage } from "@t3tools/provider-core/server/threadTitleContext"; // Public PR subjects and existing title scenarios. Repeated text adds context pressure. export const threadTitleEvaluationCases = [ diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 5c0596237b7e..b9344451e64f 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -53,7 +53,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import { expandHomePathWith } from "../pathExpansion.ts"; +import { expandHomePathWith } from "@t3tools/provider-core/server/pathExpansion"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f90d344efaff..f1e027f9eb85 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -10,7 +10,7 @@ import * as NetAddress from "effect/net/NetAddress"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import * as McpProviderSession from "./McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; export interface McpCredentialRequest { readonly threadId: ThreadId; diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index b29d81a4948e..52458c8401e8 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -19,7 +19,7 @@ import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 18b444b85177..eaa776173af3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -28,7 +28,7 @@ import { OrchestratorProjectionError, OrchestratorThreadAboveModeLimitError, } from "../orchestration-v2/Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index cfca51b629c8..496c6260d9b4 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -62,15 +62,15 @@ import { ProviderAdapterProtocolError, type ProviderAdapterV2Shape, type ProviderAdapterV2TurnInput, -} from "../orchestration-v2/ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; -import { checkpointWorkspace } from "../orchestration-v2/testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { decodeProviderReplayNdjson, materializeReplayTranscriptWorkspace, -} from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; +} from "@t3tools/provider-testing/replayTranscript"; import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index 1fdc8e0c50ca..ce2d6428091c 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -17,7 +17,7 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2McpApps, ProviderAdapterV2SessionRuntime, -} from "../orchestration-v2/ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index c95606ccd215..41902c528aeb 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -24,7 +24,7 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2McpApps, ProviderAdapterV2McpTool, -} from "../orchestration-v2/ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index dfefe387a58a..c6ad1fe57e71 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -55,7 +55,7 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { extractXAiAcpSubagentEndNotice, @@ -64,13 +64,13 @@ import { normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, } from "../../provider/acp/XAiAcpExtension.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterProtocolError, ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { AcpProviderCapabilitiesV2, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index cf9c20e2860a..960ac6f57f01 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -14,7 +14,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; export const AcpReplayTranscript = Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 7fa7b4874d8f..0c8332743b15 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -63,8 +63,8 @@ import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge, } from "../../mcp/AcpMcpOverAcpBridge.ts"; -import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { applyAcpAgentTerminalUpdate, acpContentBlockDisplayText, @@ -97,17 +97,20 @@ import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { t3AcpPromptWithInstructions, type T3AcpInstructionState, -} from "../../provider/T3OrchestrationInstructions.ts"; +} from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { type BackgroundWork, type BackgroundWorkReport, backgroundWorkNotification, } from "../Notification.ts"; -import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts"; -import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + makeProviderFailure, + makeProviderRetryTurnItem, +} from "@t3tools/provider-core/server/failure"; +import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, @@ -117,7 +120,7 @@ import { makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 140476f4fa27..85ae7368ee67 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -23,13 +23,13 @@ import type { import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { decodeAcpReplayTranscript, makeAcpReplayCompletenessAssertion, makeAcpReplayRuntime, } from "./AcpAdapterV2.testkit.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; import { ACP_REGISTRY_PROVIDER, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 4289d0314b38..7d829741862d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -10,9 +10,9 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index be73eaec6819..f1a1ef3a583f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -32,14 +32,14 @@ import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRu import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 6905e72fafb5..ed00f33728b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -25,8 +25,8 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as ServerConfig from "../../config.ts"; import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import { makeAntigravityAcpAdapterFlavor, makeAntigravityAdapterV2, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1ee304556cc1..3d8f8f75091a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -34,7 +34,7 @@ import { makeAntigravityUserInputResponse, normalizeAntigravityToolCall, } from "../../provider/acp/AntigravityProtocol.ts"; -import type { IdAllocatorV2 } from "../IdAllocator.ts"; +import type { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 595fe28b1b9e..5273c37d7d68 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -52,7 +52,7 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; @@ -66,11 +66,11 @@ import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 3e70e3d9a8ff..cc8a5bef6f5a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -31,15 +31,15 @@ import { resolveClaudeSdkExecutablePath, } from "../../provider/Drivers/ClaudeExecutable.ts"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import { randomUuidV4 } from "../RandomUuid.ts"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import { makeReplayServerConfig, type OrchestratorV2ProviderReplayHarness, } from "../testkit/ProviderReplayHarness.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; export const CLAUDE_AGENT_SDK_REPLAY_PROTOCOL = "claude-agent-sdk.query" as const; /** diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1ad916ec2548..17876e430879 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -90,7 +90,7 @@ import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispa import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { claudeSignedOutMessage, makeClaudeEnvironment, @@ -109,22 +109,28 @@ import { claudeRateLimitEventToUpdate, type ClaudeScopedLimitNames, } from "../../provider/claudeUsageLimits.ts"; -import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; +import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; -import { mcpToolPresentation, normalizeMcpText } from "../../provider/McpToolPresentation.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { + mcpToolPresentation, + normalizeMcpText, +} from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { + makeProviderFailure, + makeProviderRetryTurnItem, +} from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import { type BackgroundWorkReport, backgroundWorkNotification } from "../Notification.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 862810662310..cadf66661143 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,7 +1,7 @@ import * as NodeOS from "node:os"; import { historyResponseItems } from "../ContextHandoffBudget.ts"; -import type { ProviderAdapterV2HistoricalContext } from "../ProviderAdapter.ts"; +import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderTextDeltaCoalescer, type ProviderTextDeltaUpdate, @@ -55,10 +55,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import packageJson from "../../../package.json" with { type: "json" }; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as EffectWorker from "../EffectWorker.ts"; import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; @@ -69,7 +69,7 @@ import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 403d85afae7d..0941a1f88e3a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -14,12 +14,12 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterOpenSessionError } from "../ProviderAdapter.ts"; -import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterOpenSessionError } from "@t3tools/provider-core/server/ProviderAdapter"; +import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; export class CodexReplayTranscriptDecodeError extends Schema.TaggedError()( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index fefc1ece4fc9..f87525e9c8f7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -12,7 +12,7 @@ import { completeCodexTurnTokenUsage, type CodexTurnTokenUsageState, } from "../../provider/CodexTurnTokenUsage.ts"; -import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; +import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts"; import { @@ -87,7 +87,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { buildCodexAdditionalContext, buildCodexDeveloperInstructions, @@ -107,8 +107,8 @@ import { } from "../../provider/EventNdjsonLogger.ts"; import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { MCP_APP_EXTENSION_ID, MCP_APP_MIME_TYPE, @@ -120,8 +120,8 @@ import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; -import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; +import { IdAllocatorV2, type IdAllocatorV2Shape } from "@t3tools/provider-core/server/IdAllocator"; import { type ProviderContinuationRequest, ProviderContinuationRequests, @@ -131,8 +131,8 @@ import { makeProviderFailure, makeProviderFailureTurnItem, makeProviderRetryTurnItem, -} from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +} from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, @@ -160,7 +160,7 @@ import { type ProviderAdapterV2InterruptInput, type ProviderAdapterV2SteerInput, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 8b0bdfb98b97..cf37340d0e18 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -23,9 +23,9 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import { cursorMcpServers, cursorRuntimeAgentPolicy, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 42ec1f7a8ea4..a3a72f741138 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -20,8 +20,8 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; @@ -32,7 +32,7 @@ import { cursorSdkModelSelection, makeCursorAgentOptions, } from "./CursorAdapterV2.ts"; -import type { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts"; const CursorAgentSdkReplayTranscript = Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 5ade7c13911b..077cb6ead337 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -44,8 +44,8 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; -import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts"; import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; import { @@ -53,22 +53,22 @@ import { hasCursorSkillMention, rewriteCursorSkillMentions, } from "../../provider/Drivers/CursorSkills.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, } from "../AttachmentPrompt.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 59e09b2b44c0..4284de473a17 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -25,10 +25,10 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as ServerConfig from "../../config.ts"; import * as ProjectStore from "../ProjectStore.ts"; import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; -import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import * as RuntimePolicy from "../RuntimePolicy.ts"; import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 96d0bedab89a..30438a421408 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -16,10 +16,10 @@ import { GROK_ACP_INITIALIZE_META, } from "../../provider/acp/GrokAcpSupport.ts"; import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index b446e7ff02df..5f28bd1b419f 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -1,4 +1,4 @@ -import { makeProviderFailure } from "../ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { XAiPromptFailureText, isXAiTaskCompletedWakeNotification, @@ -53,18 +53,18 @@ import { XAiAskUserQuestionRequest, XAiExitPlanModeRequest, } from "../../provider/acp/XAiAcpExtension.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts index ffc520bed1c9..a896f876d1ad 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts @@ -32,15 +32,15 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import type { MuseItem } from "../../provider/museProtocol.ts"; import type { MuseSdkHost } from "../../provider/museSdk.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 826179c91828..182b67c20aad 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -35,7 +35,7 @@ import { type MuseSdkHost, type MuseSdkHostOptions, } from "../../provider/museSdk.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts index 406ea9cd59ca..7384afad822a 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts @@ -36,7 +36,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import { museModelCapabilities, @@ -71,7 +71,7 @@ import { providerMessageTextWithAttachmentPaths, isProviderNativeImageAttachment, } from "../AttachmentPrompt.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterEnsureThreadError, ProviderAdapterForkThreadError, @@ -94,11 +94,11 @@ import { type ProviderAdapterV2ThreadSnapshot, type ProviderAdapterV2TurnInput, type ProviderAdapterV2TurnMessage, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; import type * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; const MUSE_PROVIDER = ProviderDriverKind.make("muse"); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 7274de8c386f..1be594002791 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -41,9 +41,9 @@ import { describe } from "vite-plus/test"; import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime, -} from "../ProviderAdapter.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index e8fd1a0258f5..466349cc37ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -23,9 +23,9 @@ import * as UrlParams from "effect/http/UrlParams"; import * as ServerConfig from "../../config.ts"; import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import type { ProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import { makeReplayServerConfig, type OrchestratorV2ProviderReplayHarness, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 102eb721bb1a..ef05da600de1 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -77,26 +77,26 @@ import { parseOpenCodeModelSlug, type OpenCodeRuntimeError, } from "../../provider/opencodeRuntime.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; -import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; +import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, } from "../SubagentProjection.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts"; import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index a8e28a377d1a..c52447fafaeb 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -34,7 +34,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../../config.ts"; import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { advanceOpenCodePromptAdmission, @@ -49,7 +49,7 @@ import { OPENCODE_PROVIDER, reconcileOpenCodePromptAdmissionStatus, } from "./OpenCodeAdapterV2.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index ea7deb384644..aaf50f24a6ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -9,8 +9,8 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../../config.ts"; import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import { makeReplayServerConfig, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 510a4c859d36..8b3216fbaaa7 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -56,28 +56,28 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; -import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, } from "../../provider/NativeProtocolLogging.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 4db9305cd2e6..8789b5268498 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -33,15 +33,16 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import * as ServerConfig from "../../config.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "../ContextHandoffBudget.ts"; import { makePiAdapterV2, @@ -51,11 +52,11 @@ import { } from "./PiAdapterV2.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-pi-v2-adapter-", -}).pipe(Layer.provide(NodeServices.layer)); - -const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); +const layerTest = Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), +); const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -330,7 +331,7 @@ const makeAdapter = Effect.fnUntraced(function* ( continuationRequests?: PiAdapterV2Options["continuationRequests"], ) { const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const spawner = forkFake === undefined @@ -363,7 +364,7 @@ const makeAdapter = Effect.fnUntraced(function* ( spawner, fileSystem, idAllocator, - serverConfig, + host, }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index c5c4ead2f3e3..a66a6a2b1a5d 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -30,14 +30,11 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { PI_PROVIDER, PiAdapterV2Driver } from "./PiAdapterV2.ts"; export const PI_RPC_REPLAY_PROTOCOL = "pi.rpc-jsonl"; @@ -414,10 +411,6 @@ export function layer(input: { readonly launchArgs: string; readonly environment?: ProviderInstanceEnvironment; }) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`pi-${input.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromDrivers({ drivers: [PiAdapterV2Driver], configMap: { @@ -430,7 +423,13 @@ export function layer(input: { }, }).pipe( Layer.provide(input.spawner), - Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), + Layer.provide( + Layer.mergeAll( + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), + NodeServices.layer, + IdAllocator.layer, + ), + ), ); } diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index b05454aefc12..520992dbc4e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -60,27 +60,29 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { ProviderHost, type ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { expandPiSkillReference, parsePiCompactCommand, parsePiDiscoveredCommands, type PiCompactCommand, } from "../../provider/PiCommands.ts"; -import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; -import * as IdAllocator from "../IdAllocator.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, -} from "../ProviderAdapterDriver.ts"; -import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts"; -import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; +import { + makeProviderFailure, + makeProviderRetryTurnItem, +} from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { makePiRpcConnection, parsePiModelSlug, @@ -229,7 +231,7 @@ export interface PiAdapterV2Options { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHostShape; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -405,7 +407,7 @@ export function makePiAdapterV2( input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, ) { const scope = yield* Effect.scope; - const cwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; + const cwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const provideCacheFs = (effect: Effect.Effect) => effect.pipe( @@ -423,7 +425,7 @@ export function makePiAdapterV2( // hook. Materialize it even when this session has no MCP credential so // Supervised never silently degrades to unrestricted tool execution. const extensionPath = yield* provideCacheFs( - materializePiT3McpExtension(options.serverConfig.providerStatusCacheDir), + materializePiT3McpExtension(options.host.paths.providerStatusCacheDir), ); const resolvedLaunchArgs = resolvePiLaunchArgs(options.settings.launchArgs); if (!resolvedLaunchArgs.ok) { @@ -2335,10 +2337,7 @@ export function makePiAdapterV2( const images: Array<{ type: "image"; data: string; mimeType: string }> = []; const extraLines: Array = []; for (const attachment of attachments) { - const path = resolveAttachmentPath({ - attachmentsDir: options.serverConfig.attachmentsDir, - attachment, - }); + const path = options.host.resolveAttachmentPath(attachment); if (path === null) continue; if (attachment.mimeType.startsWith("image/")) { const bytes = yield* options.fileSystem.readFile(path); @@ -3202,7 +3201,7 @@ export type PiAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig; + | ProviderHost; export const PiAdapterV2Driver: ProviderAdapterDriver = { driverKind: PI_DRIVER_KIND, @@ -3214,7 +3213,7 @@ export const PiAdapterV2Driver: ProviderAdapterDriver()("PiRpcError", { operation: Schema.String, diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts index d5c9f0669125..e11e953f8433 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts @@ -8,7 +8,7 @@ * Do not import t3code modules from the string body. The Pi process resolves * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. */ -import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; +import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts index 9de591560478..5f3777821780 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts @@ -2,7 +2,7 @@ import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; +import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; import { PI_T3_MCP_EXTENSION_FILENAME, PI_T3_MCP_EXTENSION_SOURCE, diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index c1a72d05f685..4d4950bcb4b7 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -29,11 +29,11 @@ import type { ProviderAdapterV2InterruptInput, ProviderAdapterV2Shape, ProviderAdapterV2TurnInput, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index 91d197dc82dd..bb7619f6a8ab 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -28,7 +28,7 @@ import { VcsProcessTimeoutError } from "@t3tools/contracts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; const layerProjectionStoreTest = Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index ab34b62367b5..6be935c9c23b 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -17,7 +17,7 @@ import * as Schema from "effect/Schema"; import * as CheckpointService from "./CheckpointService.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; export class CheckpointCaptureExecutionError extends Schema.TaggedError()( diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 34bf5cce6a53..f86e1b7402d9 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -20,7 +20,7 @@ import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index bb37290363ac..217a73697133 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -23,10 +23,10 @@ import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { ProviderAdapterV2RollbackThreadInput } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2RollbackThreadInput } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index b1532a74c6dc..f9747abe2093 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -20,10 +20,10 @@ import { } from "./CheckpointRestoreSafety.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; -import { IdAllocatorV2 } from "./IdAllocator.ts"; +import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { ProviderAdapterV2RollbackTarget } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2RollbackTarget } from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index 4935ffb9fbd4..b29eb33b69a2 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/contracts"; import * as CheckpointDiffQuery from "../checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/CheckpointService.test.ts b/apps/server/src/orchestration-v2/CheckpointService.test.ts index cef73dd1cbbc..3bb6d0be8452 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.test.ts @@ -16,7 +16,7 @@ import * as Layer from "effect/Layer"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as CheckpointService from "./CheckpointService.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; it.effect.each([false, true, "interrupt"] as const)( "materializes baseline, lookup fails=%s", diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index cb514529349b..c0bf30311a24 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const CHECKPOINT_REFS_PREFIX = "refs/t3/orchestration-v2/checkpoints"; const ROOT_CHECKPOINT_SCOPE_NAME = "root"; diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index a6cac12037e2..fe4340474bcb 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -31,14 +31,14 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "./ThreadLaunchService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const sessionId = "automatic-delivery-session"; const settings = Schema.decodeSync(ClaudeSettings)({}); diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 2c6c878b25e9..6f385a128b39 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -1,4 +1,4 @@ -import type { ProviderAdapterV2HistoricalContext } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; import { assert, describe, it } from "@effect/vitest"; import { ContextHandoffId, diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 52a80448f8c3..f3c4a846b7e7 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -2,7 +2,8 @@ import type { OrchestrationV2ContextHandoff, OrchestrationV2ProviderThread, } from "@t3tools/contracts"; -import type { ProviderAdapterV2HistoricalContext } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; +import { ContextHandoffBudgetError } from "@t3tools/provider-core/server/failure"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { historyCost, renderHistory, selectHistory } from "./ContextHandoffBudget.ts"; @@ -146,14 +147,6 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH }, ); -export class ContextHandoffBudgetError extends Schema.TaggedError()( - "ContextHandoffBudgetError", - {}, -) { - override get message() { - return "Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated."; - } -} export class ContextHandoffDeliveryUncertainError extends Schema.TaggedError()( "ContextHandoffDeliveryUncertainError", {}, diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index 18c7e982af7e..9f24d165c55d 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -13,7 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ContextHandoffService from "./ContextHandoffService.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const layerTest = ContextHandoffService.layer.pipe(Layer.provide(IdAllocator.layer)); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 226c2119d3ea..0d66db6deb3f 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -21,7 +21,7 @@ import { renderHistory, selectHistory, } from "./ContextHandoffBudget.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; export class ContextHandoffPrepareError extends Schema.TaggedError()( "ContextHandoffPrepareError", diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 0344e3aed4cf..ed2ae984501e 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -27,7 +27,7 @@ import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -36,7 +36,7 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import { continueRestartedRun } from "./RestartContinuation.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts index dca74400e821..044b1b66bcdc 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts @@ -13,7 +13,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 5b70ae8b7fd5..9f4549370377 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -50,7 +50,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 024b94c24236..873ca407a701 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -31,12 +31,12 @@ import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts"; import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts"; import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { decodeProviderReplayNdjson, readProviderReplayTranscript, -} from "./testkit/ReplayTranscriptNdjson.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +} from "@t3tools/provider-testing/replayTranscript"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; /** Held until the scenario releases it, so the turn is still running meanwhile. */ diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index ba442d99bfec..3e486a14f0de 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -66,7 +66,7 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index c68fc0ace5e9..76a4bd8c1bec 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -25,7 +25,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1d7f907a5196..226108f6a503 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -91,7 +91,7 @@ import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; -import { IdAllocatorV2 } from "./IdAllocator.ts"; +import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { @@ -107,10 +107,10 @@ import { type ProjectionCheckpointContext, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; -import { makeProviderFailure } from "./ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as RunExecutionService from "./RunExecutionService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts index f79c974902a4..3a11ab7c2722 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts @@ -20,13 +20,16 @@ import * as Stream from "effect/Stream"; import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { + ProviderAdapterOpenSessionError, + type ProviderAdapterV2Shape, +} from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, -} from "./ProviderAdapterDriver.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts index d487aa18c9be..984fd586e5ea 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts @@ -17,8 +17,8 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import { ProviderAdapterDriverCreateError, type AnyProviderAdapterDriver, -} from "./ProviderAdapterDriver.ts"; -import * as ProviderAdapter from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/adapterDriver"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const isProviderSetupError = Schema.is(ProviderSetupError); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 0c2df4e9cf44..2ed8b465b2c3 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -18,7 +18,7 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import * as TestClock from "effect/testing/TestClock"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 2520847c7748..ac254a0ae27c 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index c9740adfbf0c..1ccbdaf95970 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -31,11 +31,11 @@ import { toolOutputImages } from "@t3tools/shared/toolOutput"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +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 { makeProviderFailure } from "./ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, type ProviderEventRouteIdentity, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index dcaa956d0789..b794883c092d 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -29,9 +29,9 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; -import { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; -import { makeProviderFailureTurnItem } from "./ProviderFailure.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; +import { makeProviderFailureTurnItem } from "@t3tools/provider-core/server/failure"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { stripUnservedToolOutputImageBytes } from "./toolOutputImageBytes.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts index 6d92c82904cc..dd2a05f53909 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts @@ -31,7 +31,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts index 443584ce69fc..126746648f97 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts @@ -12,7 +12,7 @@ import { expect, it } from "vite-plus/test"; import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index cb06238395a6..da764d41d730 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -21,7 +21,7 @@ import * as Ref from "effect/Ref"; import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 27a79b9fa757..7a063bec065b 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -18,7 +18,7 @@ import * as Schema from "effect/Schema"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun } from "./RestartContinuation.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts index 2e1911bceb8e..4a6d6a6a91d1 100644 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts @@ -7,7 +7,7 @@ import { } from "@t3tools/contracts"; import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; -import { acpSelectionTransition } from "./ProviderSelectionTransition.ts"; +import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; const selection = (model: string, effort = "medium"): ModelSelection => ({ instanceId: ProviderInstanceId.make("acp_test"), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 2f2b0402f620..bb994377f4bf 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -35,14 +35,14 @@ import { HttpServer } from "effect/http"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import { ProviderAdapterEventStreamError, @@ -51,7 +51,7 @@ import { type ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2Shape, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index d181497ec275..f467b8527e89 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -40,11 +40,11 @@ import { } from "../observability/Metrics.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import { ProviderAdapterEventStreamError, @@ -53,7 +53,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2EventSubscription, type ProviderAdapterV2SessionRuntime, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts index 50c7e561de63..784d03de6dcb 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts @@ -7,8 +7,8 @@ import type { } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; -import type { ProviderContinuationIdentity } from "../provider/ProviderDriver.ts"; -import type { ProviderSelectionTransitionPlan } from "./ProviderSelectionTransition.ts"; +import type { ProviderContinuationIdentity } from "@t3tools/provider-core/server/driver"; +import type { ProviderSelectionTransitionPlan } from "@t3tools/provider-core/server/selectionTransition"; export type ProviderSessionTransition = | { readonly type: "reuse" } diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index 5cf3f9246c6b..712b9e6e10f3 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -12,9 +12,9 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; -import { acpSelectionTransition } from "./ProviderSelectionTransition.ts"; +import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import * as ProviderSwitch from "./ProviderSwitchService.ts"; const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 0808386da538..42b3943e71d0 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -24,7 +24,7 @@ import * as Stream from "effect/Stream"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 06761c13b3fd..54e55c40a3cc 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -29,10 +29,10 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { ProviderAdapterEventStreamError } from "./ProviderAdapter.ts"; +import { ProviderAdapterEventStreamError } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 227aab2256e2..9bf834c0efa4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -42,11 +42,11 @@ import { type ProviderAdapterV2Error, type ProviderAdapterV2HistoricalContext, type ProviderAdapterV2SessionRuntime, -} from "./ProviderAdapter.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; -import { makeProviderFailure } from "./ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as RunExecutionService from "./RunExecutionService.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; import { diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 6a790c966eab..dcbf4aa63728 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -21,7 +21,7 @@ import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index f15102962555..c74346058077 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -42,7 +42,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as CheckpointService from "./CheckpointService.ts"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterEventStreamError, ProviderAdapterTurnStartError, @@ -50,7 +50,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, type ProviderAdapterV2TurnInput, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 5f3e36ffd66a..111aced2865e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -40,17 +40,20 @@ import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as EventSink from "./EventSink.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderAdapterV2Event, ProviderAdapterV2RuntimePolicy, ProviderAdapterV2SessionRuntime, ProviderAdapterV2TurnMessage, -} from "./ProviderAdapter.ts"; -import { ProviderAdapterTurnStartError } from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +import { ProviderAdapterTurnStartError } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; -import { makeProviderFailure, makeProviderFailureTurnItem } from "./ProviderFailure.ts"; +import { + makeProviderFailure, + makeProviderFailureTurnItem, +} from "@t3tools/provider-core/server/failure"; import * as RunFinalizationService from "./RunFinalizationService.ts"; export interface ProviderEventRoutingState { diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index b4cf0630fb8a..6719486d5377 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -14,7 +14,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index 7387de15748f..a512b7dcabe9 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -15,7 +15,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectStore from "./ProjectStore.ts"; /** diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts index 6bad62ca2675..eb4670258101 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts @@ -12,7 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type { ProviderAdapterV2RuntimeRequestResponseInput } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2RuntimeRequestResponseInput } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 0737be290afd..425336ac29e0 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -33,10 +33,10 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2Shape, type ProviderAdapterV2TurnInput, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const driver = ProviderDriverKind.make("codex"); const providerInstanceId = ProviderInstanceId.make("codex-restart-test"); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index bbea47cee599..8c28cbc03ce9 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -28,10 +28,10 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2Shape, type ProviderAdapterV2TurnInput, -} from "./ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index d9a87e0df757..3a502e9e1b96 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -20,7 +20,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { applyToProjection, emptyProjection } from "./ProjectionStore.ts"; import { planThreadDeletion } from "./ThreadDeletion.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 86d1e8050267..077aa5726bd0 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -7,7 +7,7 @@ import type * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; -import type { IdAllocatorV2, IdAllocatorV2Error } from "./IdAllocator.ts"; +import type { IdAllocatorV2, IdAllocatorV2Error } from "@t3tools/provider-core/server/IdAllocator"; export interface ThreadDeletionPlan { readonly events: ReadonlyArray; diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 3fe92c6b49fc..77e95378c8bc 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -21,7 +21,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 3395c0320460..bfbd078e00c5 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -53,8 +53,8 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; -import * as IdAllocator from "./IdAllocator.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index b4a6a9ce8f42..10af1aef454d 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -43,10 +43,10 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type * as Orchestrator from "./Orchestrator.ts"; -import { makeProviderFailure } from "./ProviderFailure.ts"; -import { randomUuidV4 } from "./RandomUuid.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import * as ThreadManagement from "./ThreadManagementService.ts"; export type ThreadLaunchWorkspaceStrategy = diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 60a251d019fa..43a77a50e75c 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -24,7 +24,7 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 837f08927164..392dc3fdaee7 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -22,7 +22,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 4d3961019314..dd3aeb52797f 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -19,8 +19,8 @@ import type { OrchestratorV2Error } from "./Orchestrator.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; -import { formatThreadTitleContext } from "../textGeneration/ThreadTitleContext.ts"; -export { formatThreadTitleContext } from "../textGeneration/ThreadTitleContext.ts"; +import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; +export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; export class ThreadTitleRegenerationService extends Context.Service< ThreadTitleRegenerationService, diff --git a/apps/server/src/orchestration-v2/assistantStreaming.test.ts b/apps/server/src/orchestration-v2/assistantStreaming.test.ts index 72b7cb065b1c..2580936b0dfb 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.test.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { MessageId, NodeId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { makeAssistantStreamingFilter, splitBufferedAssistantText } from "./assistantStreaming.ts"; -import type { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; const message = (text: string, streaming = true): ProviderAdapterV2Event => ({ type: "message.updated", diff --git a/apps/server/src/orchestration-v2/assistantStreaming.ts b/apps/server/src/orchestration-v2/assistantStreaming.ts index fbac5013d635..4c70dbbfc35e 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.ts @@ -1,5 +1,5 @@ import type { ResponseStreamingMode } from "@t3tools/contracts"; -import type { ProviderAdapterV2Event } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; // An opening fence may sit at any indentation, since fences inside list // items are indented past the marker. A closing fence may be indented at most diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 4e912c4d6a2f..c9d2b3974713 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -19,7 +19,7 @@ import { type OpenCodeAdapterV2DriverEnv, } from "./Adapters/OpenCodeAdapterV2.ts"; import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; -import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; +import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; export type BuiltInProviderAdapterDriversV2Env = | AcpRegistryAdapterV2DriverEnv diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index 65d8100d9195..803bc7ce0902 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -48,10 +48,10 @@ import { ProviderAdapterProtocolError, type ProviderAdapterV2Event, type ProviderAdapterV2Shape, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "../testkit/ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const PROJECT_ID = "project:cutover"; const ACTIVE_THREAD = "thread:cutover:active"; diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts index 86fcff68b7da..90c29723407a 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.ts @@ -31,7 +31,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as EventSink from "../EventSink.ts"; -import { randomUuidV4 } from "../RandomUuid.ts"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; const IMPORT_EVENT_PREFIX = "migration:v1"; const TRANSCRIPT_EVENT_BATCH_SIZE = 100; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 79411e6fcc55..aed7f73e6c0f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -50,7 +50,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -67,7 +67,10 @@ import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { + ProviderAdapterV2SessionRuntime, + ProviderAdapterV2Shape, +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 9cf3351f5d25..d12491cc0eb0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -23,7 +23,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index d20a8d0b6160..67f7aa946815 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import { classifyClaudeNativeTool } from "../Adapters/ClaudeAdapterV2.ts"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../Orchestrator.ts"; import { userFacingDispatchErrorMessage } from "../UserFacingErrors.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; @@ -19,7 +19,7 @@ import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { subagentInput } from "./fixtures/subagent/input.ts"; import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; -import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayTranscriptNdjson.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; import { CLAUDE_MODEL_SELECTION, materializeFixtureInput } from "./fixtures/shared.ts"; import { THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER, @@ -33,7 +33,7 @@ import { THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER, THREAD_MERGE_BACK_SOURCE_MARKER, } from "./fixtures/shared.ts"; -import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const readTranscript = Effect.fn("readClaudeReplayFixture")(function* (file: URL) { return yield* readProviderReplayTranscript(file); diff --git a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts index c04f9b326c81..eb2482cb6547 100644 --- a/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts @@ -18,7 +18,7 @@ import { THREAD_MERGE_BACK_SIBLINGS_SOURCE_MARKER, THREAD_MERGE_BACK_SOURCE_MARKER, } from "./fixtures/shared.ts"; -import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const PROVIDER_THREAD_RESUME_FIRST_FINAL = "provider thread resume fixture first turn complete"; const PROVIDER_THREAD_RESUME_SECOND_FINAL = "provider thread resume fixture second turn complete"; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts index e0cd30fe5ba3..3b0956ecb540 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts @@ -4,11 +4,11 @@ import { OrchestrationV2Command, ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { materializeFixtureInput } from "./fixtures/shared.ts"; -import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const decodeCommand = Schema.decodeUnknownEffect(OrchestrationV2Command); const readTranscript = Effect.fn("readOrchestratorReplayContractTranscript")(function* (file: URL) { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index a3925ea409c9..db7ee893b355 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -21,7 +21,7 @@ import { } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; import { MuseOrchestratorReplayHarness } from "../Adapters/MuseAdapterV2.testkit.ts"; import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { messageRestartInput } from "./fixtures/message_steering/input.ts"; @@ -36,12 +36,12 @@ import { runOrchestratorV2ProviderReplayScenario, type OrchestratorV2ProviderReplayHarness, } from "./ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; import { - materializeReplayTranscriptRuntimeInstructions, materializeReplayTranscriptWorkspace, readProviderReplayTranscript, -} from "./ReplayTranscriptNdjson.ts"; +} from "@t3tools/provider-testing/replayTranscript"; const readTranscript = Effect.fn("readOrchestratorReplayTranscript")(function* (file: URL) { return yield* readProviderReplayTranscript(file); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 21ba303966ec..b3c979715b85 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -20,7 +20,7 @@ import { makeCursorAgentSdkReplayRunner, } from "../Adapters/CursorAdapterV2.testkit.ts"; import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { @@ -43,12 +43,12 @@ import { projectionFor, } from "./fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts"; import { - materializeReplayTranscriptRuntimeInstructions, materializeReplayTranscriptWorkspace, readProviderReplayTranscript, -} from "./ReplayTranscriptNdjson.ts"; +} from "@t3tools/provider-testing/replayTranscript"; const FIRST_FINAL = "provider thread resume fixture first turn complete"; const SECOND_FINAL = "provider thread resume fixture second turn complete"; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts index 320310a94717..574eaa40e147 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts @@ -10,7 +10,7 @@ import { ClaudeOrchestratorReplayHarness, makeClaudeRestartReplayHarness, } from "../Adapters/ClaudeAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as EffectWorker from "../EffectWorker.ts"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; @@ -23,8 +23,8 @@ import { import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; -import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; -import { readProviderReplayTranscript } from "./ReplayTranscriptNdjson.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import { readProviderReplayTranscript } from "@t3tools/provider-testing/replayTranscript"; const SCENARIO = "claude_background_subagent_after_root"; const SESSION_ID = "cca274e4-25ae-4171-b972-bbb31118517e"; diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0bb2d5053b4b..3d8ef589050e 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -23,7 +23,7 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as Orchestrator from "../Orchestrator.ts"; -import type { ProviderReplayGate } from "./ProviderReplayGate.testkit.ts"; +import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; export type OrchestratorV2ScenarioStep = | { diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 230ad7aa0feb..1307b2a77df6 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -29,7 +29,7 @@ import * as EffectOutbox from "../EffectOutbox.ts"; import * as EffectWorker from "../EffectWorker.ts"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../Orchestrator.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProjectStore from "../ProjectStore.ts"; @@ -59,7 +59,10 @@ import { type OrchestratorV2Scenario, type OrchestratorV2ScenarioResult, } from "./OrchestratorScenario.ts"; -import { makeProviderReplayGate, type ProviderReplayGate } from "./ProviderReplayGate.testkit.ts"; +import { + makeProviderReplayGate, + type ProviderReplayGate, +} from "@t3tools/provider-testing/replayGate"; export function makeReplayServerConfig( scenario: string, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 40569de3c7b5..732e04408c27 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -59,9 +59,9 @@ import { ProviderAdapterTurnStartError, type ProviderAdapterV2Shape, type ProviderAdapterV2SessionRuntime, -} from "../ProviderAdapter.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { CLAUDE_MODEL_SELECTION, CODEX_MODEL_SELECTION, @@ -69,7 +69,7 @@ import { GROK_MODEL_SELECTION, } from "./fixtures/shared.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; -import { checkpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs index f17d4b5ef483..203360ee9215 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs +++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs @@ -13,6 +13,8 @@ const [Effect, Layer, FileSystem] = await Promise.all([ load("FileSystem"), ]); const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); +const providerCore = (file) => + import(NodeURL.pathToFileURL(root + "/packages/provider-core/src/server/" + file + ".ts")); const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project, Auth] = await Promise.all([ app("orchestration-v2/ProviderTurnStartService"), @@ -20,7 +22,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project app("orchestration-v2/RunExecutionService"), app("orchestration-v2/ProviderSessionManager"), app("orchestration-v2/RuntimePolicy"), - app("orchestration-v2/IdAllocator"), + providerCore("IdAllocator"), app("orchestration-v2/EventSink"), app("orchestration-v2/ContextHandoffService"), app("git/GitWorkflowService"), diff --git a/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts new file mode 100644 index 000000000000..18f87d1f7a4f --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts @@ -0,0 +1,70 @@ +import type { ProviderDriverKind, ProviderReplayTranscript } from "@t3tools/contracts"; + +import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; + +/** Adds current runtime context to legacy prompt expectations, keeping outbound matching exact. */ +export function materializeReplayTranscriptRuntimeInstructions( + transcript: ProviderReplayTranscript, + runtime: { readonly driver: ProviderDriverKind; readonly model: string }, +): ProviderReplayTranscript { + const harness = + runtime.driver === "cursor" + ? "Cursor" + : runtime.driver === "grok" + ? "Grok" + : runtime.driver === "acpRegistry" + ? "acpRegistry" + : undefined; + if (harness === undefined) return transcript; + const instructions = buildRuntimeInstructions({ harness, model: runtime.model }); + + return { + ...transcript, + entries: transcript.entries.map((entry) => { + if (entry.type !== "expect_outbound") return entry; + const frame = entry.frame; + if (typeof frame !== "object" || frame === null) return entry; + if ( + runtime.driver === "cursor" && + "type" in frame && + frame.type === "run.start" && + "message" in frame && + typeof frame.message === "string" + ) { + return frame.message.endsWith(instructions) + ? entry + : { ...entry, frame: { ...frame, message: `${frame.message}\n\n${instructions}` } }; + } + if ( + "method" in frame && + frame.method === "session/prompt" && + "params" in frame && + typeof frame.params === "object" && + frame.params !== null && + "prompt" in frame.params && + Array.isArray(frame.params.prompt) + ) { + const prompt: ReadonlyArray = frame.params.prompt; + const lastPart = prompt.at(-1); + if ( + typeof lastPart === "object" && + lastPart !== null && + "type" in lastPart && + lastPart.type === "text" && + "text" in lastPart && + lastPart.text === instructions + ) { + return entry; + } + return { + ...entry, + frame: { + ...frame, + params: { ...frame.params, prompt: [...prompt, { type: "text", text: instructions }] }, + }, + }; + } + return entry; + }), + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index a40cb9f5d863..96ba1fe2c172 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -18,7 +18,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT, @@ -31,7 +31,7 @@ import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness import { decodeProviderReplayNdjson, materializeReplayTranscriptWorkspace, -} from "./ReplayTranscriptNdjson.ts"; +} from "@t3tools/provider-testing/replayTranscript"; const CODEX_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts index e22d847037ab..30928887d308 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts @@ -19,7 +19,7 @@ import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { CODEX_MODEL_SELECTION, @@ -37,11 +37,11 @@ import { THREAD_MERGE_BACK_SOURCE_PROMPT, } from "./fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts"; -import { makeCheckpointWorkspace } from "./ReplayFixtureWorkspace.ts"; +import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { decodeProviderReplayNdjson, materializeReplayTranscriptWorkspace, -} from "./ReplayTranscriptNdjson.ts"; +} from "@t3tools/provider-testing/replayTranscript"; // These recorded 0.137 rollouts predate injection. Preserve their native fork // and turn exchanges, and assert the new history delivery at the adapter boundary. diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index ed2a32ad6eb6..1fda1cbf649d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -27,7 +27,7 @@ import type { OrchestratorV2ScenarioResult, OrchestratorV2ScenarioStep, } from "../OrchestratorScenario.ts"; -import * as IdAllocator from "../../IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { RuntimePolicyV2Override } from "../../RuntimePolicy.ts"; export const SIMPLE_PROMPT = "Respond with the following text: fixture simple ok"; diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 3fd4734069f3..2a6cc235d7e0 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -9,13 +9,12 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, decodeUtf8, type CollectedUint8StreamText, -} from "./stream/collectUint8StreamText.ts"; +} from "@t3tools/provider-core/server/collectStreamText"; export interface ProcessRunInput { readonly command: string; @@ -149,27 +148,7 @@ export class ProcessRunner extends Context.Service< const DEFAULT_TIMEOUT = "60 seconds"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ - /is not recognized as an internal or external command/i, - /n.o . reconhecido como um comando interno/i, - /non . riconosciuto come comando interno o esterno/i, - /n.est pas reconnu en tant que commande interne/i, - /no se reconoce como un comando interno o externo/i, - /wird nicht als interner oder externer befehl/i, -] as const; - -function hasWindowsCommandNotFoundMessage(output: string): boolean { - return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); -} - -export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( - function* (code: number | null, stderr: string) { - const platform = yield* HostProcessPlatform; - if (platform !== "win32") return false; - if (code === 9009) return true; - return hasWindowsCommandNotFoundMessage(stderr); - }, -); +export { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; // Untraced: no attributes, and its time is the runProcessCore span. Errors fail that span. const collectText = Effect.fnUntraced(function* (input: { diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 182426b83751..3a1ab5d0a7e6 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -11,7 +11,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as AgentSessionImporter from "./AgentSessionImporter.ts"; diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 43ce8096b8ea..2c8d6a49d4c7 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -32,7 +32,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index bc30691784db..d67b5d72639c 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -50,7 +50,7 @@ import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { createTranscriptJsonReader, diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 48ad979d151b..746c139f6fc8 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -20,7 +20,7 @@ import * as ServerConfig from "../config.ts"; import { OrchestrationEffectRequestV2 } from "../orchestration-v2/EffectOutbox.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionMaintenance from "../orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 809184b36d09..09d4f8229771 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -12,7 +12,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 247ff0aaa925..f37b4e383061 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -18,7 +18,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as EventSink from "../orchestration-v2/EventSink.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import { diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 259b5d5cf4cc..77a436aaf7be 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, @@ -18,7 +19,9 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProviderHostLive from "./ProviderHostLive.ts"; import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; import { buildAntigravityModelsFromSession, @@ -106,11 +109,18 @@ const expectedCommands = [ { name: "logout", description: "Sign out of Google" }, ]; -const layerTest = Layer.merge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ServerSettings.layerTest(), +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), ); type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index a644b020d38c..ca6c0391491a 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -23,12 +23,12 @@ import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; import { makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { buildServerProvider, isCommandMissingCause, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 831df2d172ec..5d63060493d9 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -24,7 +24,7 @@ import { probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, } from "./ClaudeProvider.ts"; -import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; +import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 37945bbb79af..0770a50f873a 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -33,12 +33,12 @@ import { providerModelsFromSettings, spawnAndCollect, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts"; -import type { ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; -import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; +import type { ProviderWorkspaceSnapshot } from "@t3tools/provider-core/server/driver"; +import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { type ClaudeScopedLimitNames, claudeUsageResponseToLimits, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index e498ed03a13e..e9cbe27997ab 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -5,7 +5,7 @@ import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS, T3_CODE_ORCHESTRATION_INSTRUCTIONS, -} from "./T3OrchestrationInstructions.ts"; +} from "@t3tools/provider-core/server/orchestrationInstructions"; const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 979849e95cbc..ac3b299b18bf 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -38,9 +38,9 @@ import { buildServerProvider, COMPACT_SLASH_COMMAND, type ServerProviderDraft, -} from "./providerSnapshot.ts"; -import { expandHomePath } from "../pathExpansion.ts"; -import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, diff --git a/apps/server/src/provider/CodexToolPresentation.ts b/apps/server/src/provider/CodexToolPresentation.ts index 42561286caec..323d068aa067 100644 --- a/apps/server/src/provider/CodexToolPresentation.ts +++ b/apps/server/src/provider/CodexToolPresentation.ts @@ -12,7 +12,7 @@ import { mcpToolPresentation as integrationToolPresentation, normalizeMcpHttpUrl as normalizedHttpUrl, normalizeMcpText as normalizedDisplayName, -} from "./McpToolPresentation.ts"; +} from "@t3tools/provider-core/server/mcpToolPresentation"; type CodexLifecycleItem = EffectCodexSchema.V2ItemCompletedNotification["item"]; diff --git a/apps/server/src/provider/CursorProvider.ts b/apps/server/src/provider/CursorProvider.ts index 7c0ab4d2e868..473a7bb8c5aa 100644 --- a/apps/server/src/provider/CursorProvider.ts +++ b/apps/server/src/provider/CursorProvider.ts @@ -20,7 +20,7 @@ import { buildServerProvider, providerModelsFromSettings, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; const CURSOR_PRESENTATION = { diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 4d01dcf6aa86..da992744b36f 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -21,24 +21,23 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/process"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; -import * as ServerSettings from "../../serverSettings.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; -import { providerModelsFromSettings } from "../providerSnapshot.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/driver"; +import { providerModelsFromSettings } from "@t3tools/provider-core/server/snapshotProbe"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -460,10 +459,7 @@ export const checkAcpRegistryProviderReadiness = Effect.fn( : snapshot; }); -export type AcpRegistryDriverEnv = - | AcpRegistryAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy - | ServerSettings.ServerSettingsService; +export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost; /** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */ export const AcpRegistryDriver: ProviderDriver = { @@ -493,7 +489,7 @@ export const AcpRegistryDriver: ProviderDriver >({ diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index e0c52801bc81..d08cacb60dcd 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -31,8 +31,9 @@ import { } from "../antigravityAuthSupport.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { AntigravityDriver } from "./AntigravityDriver.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; const hostPlatform = HostProcessPlatform.defaultValue(); const windowsHost = hostPlatform === "win32"; @@ -244,7 +245,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( }; }); -const layerTest = ServerConfig.layerTest(process.cwd(), { +const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-driver-config-", }).pipe( Layer.provideMerge(NodeServices.layer), @@ -263,6 +264,7 @@ const layerTest = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(IdAllocator.layer), ); +const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); it.layer(layerTest)("AntigravityDriver", (it) => { it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 29e103c453b6..817dbff73570 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,4 @@ -import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; +import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; @@ -18,9 +18,8 @@ import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { AcpError } from "effect-acp/errors"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; import { isAntigravityTextGenerationAvailable, makeAntigravityTextGeneration, @@ -42,12 +41,12 @@ import { type AntigravityAcpRuntimeInput, } from "../acp/AntigravityAcpSupport.ts"; import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; import { removeAntigravityRuntimeTempDirs, removeAntigravitySessionFiles, } from "../acp/AntigravitySessionFiles.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; @@ -59,8 +58,8 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +} from "@t3tools/provider-core/server/driver"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; @@ -70,7 +69,7 @@ const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -78,8 +77,7 @@ export type AntigravityDriverEnv = | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | ServerConfig.ServerConfig; /** Each instance owns its Google profile. Executable releases are shared by the environment. */ export const AntigravityDriver: ProviderDriver = { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4f32cf9d2b5a..55dfe036b98e 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,15 +24,14 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { createClaudeAdapterV2, type ClaudeAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; import * as ClaudeResetCredits from "../claudeResetCredits.ts"; @@ -50,16 +49,16 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; +} from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -95,7 +94,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | ClaudeAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto @@ -104,8 +103,7 @@ export type ClaudeDriverEnv = | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | ServerConfig.ServerConfig; export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -123,7 +121,7 @@ export const ClaudeDriver: ProviderDriver = { const { cwd } = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const modelManifest = yield* ModelManifest.ModelManifest; const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); const processEnv = mergeProviderInstanceEnvironment(environment); @@ -237,7 +235,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 70699ca669e8..8104401868a3 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 8c9c9c669faa..3ef7d925e031 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -24,7 +24,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; type ClaudeSkillScope = "user" | "project"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index f732d6d5bba5..72340b7471cf 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -32,14 +32,15 @@ import { createProviderVersionAdvisory, ProviderVersionCache, resolveLatestProviderVersion, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { CodexDriver } from "./CodexDriver.ts"; import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; -const layerTest = ServerConfig.layerTest(process.cwd(), { +const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", }).pipe( Layer.provideMerge(NodeServices.layer), @@ -81,6 +82,7 @@ const layerTest = ServerConfig.layerTest(process.cwd(), { ), ), ); +const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); // The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 0b958e2978a5..885a89b65c66 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -31,15 +31,14 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { createCodexAdapterV2, type CodexAdapterV2DriverEnv, } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as ServerSettings from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; import { @@ -51,16 +50,16 @@ import { import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; -import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -109,7 +108,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { */ export type CodexDriverEnv = | CodexAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto @@ -119,7 +118,6 @@ export type CodexDriverEnv = | Path.Path | ProviderEventLoggers.ProviderEventLoggers | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService | ServerSecretStore.ServerSecretStore | ServerEnvironment.ServerEnvironmentIdentity | CodexInstallation.CodexInstallation; @@ -148,7 +146,7 @@ export const CodexDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -232,7 +230,7 @@ export const CodexDriver: ProviderDriver = { ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 8efd0b5d994a..544e05744832 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -7,7 +7,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as PlatformError from "effect/PlatformError"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; export interface CodexHomeLayout { readonly mode: "direct" | "authOverlay"; diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index b75e1edd5414..e702548000dc 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -4,7 +4,7 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import { chatGptModels } from "../CodexChatGptModels.ts"; import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -15,8 +15,11 @@ import { probeCodexSkillsForCwd, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + type ProviderDriverCreateInput, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; import { HttpClient } from "effect/http"; @@ -28,7 +31,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const { instanceId, enabled, displayName, accentColor, config } = input; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const settings = yield* ServerSettingsService; + const host = yield* ProviderHost; const runtime = yield* makeCodexManagedRuntime({ instanceId, enabled, @@ -190,8 +193,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); const snapshot = yield* makeManagedServerProvider({ resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }), - getSettings: settings.getSettings, - streamSettings: settings.streamChanges, + getSettings: host.settings.get, + streamSettings: host.settings.changes, haveSettingsChanged: () => false, initialSnapshot: () => pending, checkProvider: check, diff --git a/apps/server/src/provider/Drivers/CursorDriver.test.ts b/apps/server/src/provider/Drivers/CursorDriver.test.ts index b0f9c2ad0d4a..8aae15d1c575 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.test.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.test.ts @@ -17,11 +17,12 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { CursorDriver } from "./CursorDriver.ts"; import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; -import { ProviderAdapterV2RuntimePolicy } from "../../orchestration-v2/ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import { Cursor } from "../cursorSdk.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; -const layerTest = ServerSecretStore.layer.pipe( +const layerDeps = ServerSecretStore.layer.pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3-cursor-driver-copy-command-", @@ -53,6 +54,7 @@ const layerTest = ServerSecretStore.layer.pipe( ), ), ); +const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); it.layer(layerTest)("CursorDriver", (it) => { it.effect( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index f0f55a8cb348..e8bd2eee22e6 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; @@ -34,10 +34,10 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; +} from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -63,7 +63,7 @@ export type CursorDriverEnv = | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ServerConfig.ServerConfig | ServerSecretStore.ServerSecretStore | ServerSettings.ServerSettingsService; @@ -79,6 +79,7 @@ export const CursorDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -234,7 +235,7 @@ export const CursorDriver: ProviderDriver = { Effect.provide(CursorSdkCatalog.layer), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts index f6d70d1a3476..f1737f71f391 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -15,9 +15,10 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; import { GrokDriver } from "./GrokDriver.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; -const layerTest = ServerConfig.layerTest(process.cwd(), { +const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-grok-driver-update-", }).pipe( Layer.provideMerge(NodeServices.layer), @@ -41,6 +42,7 @@ const layerTest = ServerConfig.layerTest(process.cwd(), { ), ), ); +const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); const noSpawner = ChildProcessSpawner.make(() => Effect.die("Disabled Grok must not spawn a process"), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 08ca7d5c715f..c253401b53e6 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -7,10 +7,9 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import * as ServerSettings from "../../serverSettings.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { GrokAdapterV2Driver, @@ -28,9 +27,9 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; +} from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { discoverGrokSkills } from "./GrokSkills.ts"; import { makeCachedProviderMaintenanceResolution, @@ -38,7 +37,7 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -76,15 +75,14 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { export type GrokDriverEnv = | GrokAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | ServerConfig.ServerConfig; export const GrokDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -101,7 +99,7 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const { cwd } = yield* ServerConfig.ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -166,7 +164,7 @@ export const GrokDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index 9d0d45d4820e..f552b0f4b2bc 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -22,7 +22,7 @@ import * as Schema from "effect/Schema"; import { ChildProcess } from "effect/process"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { spawnAndCollect } from "../providerSnapshot.ts"; +import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/apps/server/src/provider/Drivers/MuseDriver.ts index d881e34a4d92..63e164d9db96 100644 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ b/apps/server/src/provider/Drivers/MuseDriver.ts @@ -7,14 +7,13 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -import * as ServerSettings from "../../serverSettings.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; @@ -25,12 +24,12 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +} from "@t3tools/provider-core/server/driver"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeCachedProviderMaintenanceResolution, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -43,14 +42,13 @@ const decodeMuseSettings = Schema.decodeSync(MuseSettings); export type MuseDriverEnv = | IdAllocator.IdAllocatorV2 - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | ServerConfig.ServerConfig; export const MuseDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -63,7 +61,7 @@ export const MuseDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const serverConfig = yield* ServerConfig.ServerConfig; const { cwd } = serverConfig; @@ -91,7 +89,7 @@ export const MuseDriver: ProviderDriver = { accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts index c1989ee335a5..072e9e22df79 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.test.ts @@ -16,10 +16,10 @@ import { ChildProcessSpawner } from "effect/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ServerSettings from "../../serverSettings.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import * as ProviderMaintenance from "../providerMaintenance.ts"; +import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { OPENCODE_2_RESPONSES, @@ -27,6 +27,7 @@ import { replayOpenCodeServer, } from "../testFixtures/opencodeProbeResponses.ts"; import { OpenCodeDriver, openCodeUpdateFor } from "./OpenCodeDriver.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; const serverStarts: Array = []; const reachedServer = (operation: string) => @@ -47,7 +48,7 @@ const openCode2Runtime = { connectToOpenCodeServer: () => reachedServer("connect"), } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; -const layer = Layer.mergeAll( +const layerDeps = Layer.mergeAll( ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), IdAllocator.layer, ServerSettings.layerTest(), @@ -58,6 +59,7 @@ const layer = Layer.mergeAll( ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(NodeServices.layer)); +const layer = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); const create = (config: Partial, http: HttpClient.HttpClient) => OpenCodeDriver.create({ @@ -211,7 +213,7 @@ const changingRuntime = { startOpenCodeServerProcess: () => reachedServer("start"), connectToOpenCodeServer: () => reachedServer("connect"), } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; -const layerUpdate = Layer.mergeAll( +const layerUpdateDeps = Layer.mergeAll( ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }), IdAllocator.layer, ServerSettings.layerTest(), @@ -222,6 +224,7 @@ const layerUpdate = Layer.mergeAll( ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), ).pipe(Layer.provideMerge(NodeServices.layer)); +const layerUpdate = ProviderHostLive.layer.pipe(Layer.provideMerge(layerUpdateDeps)); it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { it.effect("never runs the binary for a disabled instance's update check", () => diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 688ac83db424..db4a9caa9e61 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -26,12 +26,11 @@ import { ChildProcessSpawner } from "effect/process"; import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; -import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdapter.ts"; -import * as ServerSettings from "../../serverSettings.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; @@ -59,9 +58,9 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; +} from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, @@ -69,7 +68,7 @@ import { makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -173,15 +172,14 @@ function selectOpenCodeRuntimeTextGeneration( export type OpenCodeDriverEnv = | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime | Path.Path - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | ServerConfig.ServerConfig; export const OpenCodeDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -200,7 +198,7 @@ export const OpenCodeDriver: ProviderDriver const serverConfig = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -449,7 +447,7 @@ export const OpenCodeDriver: ProviderDriver ), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>( { resolveMaintenance, diff --git a/apps/server/src/provider/Drivers/PiDriver.test.ts b/apps/server/src/provider/Drivers/PiDriver.test.ts index 7202f1784a8e..2c4751ddf812 100644 --- a/apps/server/src/provider/Drivers/PiDriver.test.ts +++ b/apps/server/src/provider/Drivers/PiDriver.test.ts @@ -15,11 +15,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; -import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ServerSettings from "../../serverSettings.ts"; +import * as ProviderHostLive from "../ProviderHostLive.ts"; import { PiDriver } from "./PiDriver.ts"; -const layerTest = Layer.mergeAll( +const layerDeps = Layer.mergeAll( ServerConfig.layerTest("/machine", { prefix: "t3-pi-driver-" }), IdAllocator.layer, ServerSettings.layerTest({ enableProviderUpdateChecks: false }), @@ -32,6 +33,8 @@ const layerTest = Layer.mergeAll( ), ).pipe(Layer.provideMerge(NodeServices.layer)); +const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); + const decodeRequest = Schema.decodeSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 9a7ec6320b7d..6bfc0ac15421 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -14,9 +14,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; import { PiAdapterV2Driver, @@ -34,14 +32,14 @@ import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, -} from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +} from "@t3tools/provider-core/server/driver"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -60,13 +58,11 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type PiDriverEnv = | PiAdapterV2DriverEnv - | BackgroundPolicy.BackgroundPolicy + | ProviderHost | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient - | Path.Path - | ServerConfig.ServerConfig - | ServerSettings.ServerSettingsService; + | Path.Path; const withInstanceIdentity = (input: { @@ -98,8 +94,8 @@ export const PiDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const { cwd } = yield* ServerConfig.ServerConfig; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const host = yield* ProviderHost; + const { cwd } = host.paths; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -148,7 +144,7 @@ export const PiDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/instanceIdentity.ts b/apps/server/src/provider/Drivers/instanceIdentity.ts index 2fbc1c4a9f08..757e6457786f 100644 --- a/apps/server/src/provider/Drivers/instanceIdentity.ts +++ b/apps/server/src/provider/Drivers/instanceIdentity.ts @@ -1,7 +1,7 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; /** * Stamp instance identity onto a `ServerProvider` snapshot produced by the diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 681d9784ded2..50c7d2a3133b 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -21,24 +21,7 @@ export class ProviderInstanceNotFoundError extends Schema.TaggedError()( - "ProviderDriverError", - { - driver: Schema.String, - instanceId: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; - } -} +export { ProviderDriverError } from "@t3tools/provider-core/server/errors"; /** * ProviderWorkspaceMissingError - The session's working directory no longer diff --git a/apps/server/src/provider/GrokProvider.ts b/apps/server/src/provider/GrokProvider.ts index bab2921e7199..2e6a51bf2114 100644 --- a/apps/server/src/provider/GrokProvider.ts +++ b/apps/server/src/provider/GrokProvider.ts @@ -30,11 +30,11 @@ import { providerModelsFromSettings, spawnAndCollect, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { GROK_DEFAULT_MODEL_SLUG, GROK_SUPPORTED_RUNTIME_MODES, diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c22efb8a094d..402102c93425 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -36,7 +36,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; -import type { ServerProviderDraft } from "./providerSnapshot.ts"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; const MODEL_MANIFEST_URL = "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; diff --git a/apps/server/src/provider/MuseProvider.test.ts b/apps/server/src/provider/MuseProvider.test.ts index 70cea88d5b9a..be7cafdbd6a1 100644 --- a/apps/server/src/provider/MuseProvider.test.ts +++ b/apps/server/src/provider/MuseProvider.test.ts @@ -8,7 +8,7 @@ import { vi } from "vite-plus/test"; import { writeFakeCli } from "../testUtils/fakeCli.ts"; import type { MuseSdkHost } from "./museSdk.ts"; -import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; +import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; import { checkMuseProviderStatus } from "./MuseProvider.ts"; const settings = Schema.decodeSync(MuseSettings); diff --git a/apps/server/src/provider/MuseProvider.ts b/apps/server/src/provider/MuseProvider.ts index 00d9d7852627..3d44431d0564 100644 --- a/apps/server/src/provider/MuseProvider.ts +++ b/apps/server/src/provider/MuseProvider.ts @@ -17,7 +17,7 @@ import { providerModelsFromSettings, spawnAndCollect, type ProviderProbeResult, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; const MUSE_PRESENTATION = { displayName: "Muse Code", diff --git a/apps/server/src/provider/OpenCodeProvider.ts b/apps/server/src/provider/OpenCodeProvider.ts index ff4580e00e2d..0b638c47821c 100644 --- a/apps/server/src/provider/OpenCodeProvider.ts +++ b/apps/server/src/provider/OpenCodeProvider.ts @@ -19,7 +19,7 @@ import { nonEmptyTrimmed, providerModelsFromSettings, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; import * as OpenCodeRuntime from "./opencodeRuntime.ts"; import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; diff --git a/apps/server/src/provider/OpenCodeServerLedger.ts b/apps/server/src/provider/OpenCodeServerLedger.ts index c6b74aeb88f1..8243e37f0481 100644 --- a/apps/server/src/provider/OpenCodeServerLedger.ts +++ b/apps/server/src/provider/OpenCodeServerLedger.ts @@ -10,7 +10,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { writeFileStringAtomically } from "../atomicWrite.ts"; import * as ServerConfig from "../config.ts"; -import { signalProcessGroup } from "../process/processGroup.ts"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); type ProcessIdentity = typeof ProcessIdentity.Type; diff --git a/apps/server/src/provider/PiProvider.ts b/apps/server/src/provider/PiProvider.ts index 2aaf9ab72e8d..4ea63f02ea49 100644 --- a/apps/server/src/provider/PiProvider.ts +++ b/apps/server/src/provider/PiProvider.ts @@ -44,11 +44,11 @@ import { providerModelsFromSettings, spawnAndCollect, type ServerProviderDraft, -} from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/snapshotProbe"; import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import { EMPTY_PI_MODEL_CAPABILITIES, thinkingCapabilitiesForPiModel, diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts index 46d27902b461..82a7c8dd288b 100644 --- a/apps/server/src/provider/ProviderAuthService.test.ts +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -23,7 +23,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts"; -import type { ProviderInstance } from "./ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderAuthService from "./ProviderAuthService.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 9dabd57104ca..24f176dd7095 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -18,53 +18,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; -export interface ProviderAuthController { - /** Equal keys mean these instances share credentials on this environment. */ - readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; - readonly reconnectProfile?: ( - methodId: string, - ) => Effect.Effect; - readonly importProfile?: ( - profile: ChatGptTransferredProfile, - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly adoptCredentials?: ( - update: Effect.Effect, - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly isChangingCredentials?: Effect.Effect; - readonly invalidate?: Effect.Effect; - readonly refreshMethods?: Effect.Effect; - readonly withAccess?: ( - task: Effect.Effect, - ) => Effect.Effect; - readonly start: ( - ownerSessionId: string, - stopSessions?: Effect.Effect, - methodId?: string, - returnUrl?: string, - callbackMode?: "server" | "client", - ) => Effect.Effect; - readonly complete: ( - ownerSessionId: string, - input: { readonly flowId: string; readonly callbackUrl: string }, - ) => Effect.Effect; - readonly cancel: ( - ownerSessionId: string, - flowId: string, - ) => Effect.Effect; - readonly respond?: ( - ownerSessionId: string, - input: ProviderAuthRespondInput, - ) => Effect.Effect; - /** The controller closes process admission before it stops routed sessions. */ - readonly logout: ( - stopSessions: Effect.Effect, - ) => Effect.Effect; - readonly subscribe: (ownerSessionId: string) => Stream.Stream; - readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; -} +export type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; interface ProviderAuthTarget { readonly instanceId: ProviderInstanceId; diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts new file mode 100644 index 000000000000..965cb47e365f --- /dev/null +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -0,0 +1,39 @@ +/** + * The server's implementation of `ProviderHost`, the only server surface + * provider drivers and adapters may use. + * + * @module provider/ProviderHostLive + */ +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +export const layer = Layer.effect( + ProviderHost, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + return ProviderHost.of({ + paths: { + cwd: config.cwd, + baseDir: config.baseDir, + stateDir: config.stateDir, + providerStatusCacheDir: config.providerStatusCacheDir, + }, + settings: { + get: serverSettings.getSettings, + changes: serverSettings.streamChanges, + subscribe: serverSettings.subscribeChanges, + }, + shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), + }); + }), +); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 7a132b40fc88..8050e426dd6c 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -50,7 +50,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; @@ -64,6 +64,7 @@ import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; +import * as ProviderHostLive from "./ProviderHostLive.ts"; const layerTestHttpClient = Layer.succeed( HttpClient.HttpClient, @@ -200,7 +201,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { // `NodeServices.layer` through `Layer.provideMerge` to satisfy that // dependency while still surfacing NodeServices to the test body (the // codex driver's `create` yields `ChildProcessSpawner` directly). - const layerBase = ServerConfig.layerTest(process.cwd(), { + const layerBaseDeps = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), @@ -230,6 +231,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); + const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( Layer.provideMerge(layerBase), ); @@ -589,7 +591,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { }), ), ); - const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe( + const layerBaseDeps = AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -610,6 +612,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); + const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( Layer.provideMerge(layerBase), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index eecd4fc5cbec..8b0ca5d6404a 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -69,7 +69,7 @@ import * as Stream from "effect/Stream"; import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; -import type { AnyProviderDriver, ProviderInstance } from "./ProviderDriver.ts"; +import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; export class ProviderInstanceRegistry extends Context.Service< ProviderInstanceRegistry, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 922eb48175d3..50627de3d337 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -58,14 +58,22 @@ import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMuta import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts"; +import * as ProviderHostLive from "./ProviderHostLive.ts"; +import type { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import type * as ServerConfig from "../config.ts"; type ProviderInstanceRegistryHydrationEnv = | Exclude< BuiltInDriversEnv, | ProviderOrchestrationAdapterInfrastructure.ProviderOrchestrationAdapterInfrastructure | AcpRegistrySupport.AcpRegistryCatalog + | ProviderHost > - | Settings.ServerSettingsService; + | Settings.ServerSettingsService + // Requirements of the `ProviderHost` the drivers receive. + | BackgroundPolicy.BackgroundPolicy + | ServerConfig.ServerConfig; /** * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. @@ -181,6 +189,7 @@ export const layer: Layer.Layer< }).pipe( Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), Layer.provide(AcpRegistryCatalog.layer), + Layer.provide(ProviderHostLive.layer), ); return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 204ef10a2721..130a63262fc4 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -3,7 +3,7 @@ import * as Layer from "effect/Layer"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; -import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; export type ProviderOrchestrationAdapterInfrastructure = diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index bfbff2e3db4c..d5fc2b2a5809 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -56,11 +56,14 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "./providerStatusCache.ts"; -import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; -import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; +import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; +import type { + ProviderInstance, + ProviderWorkspaceSnapshot, +} from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97b80f270e90..a76ad34c8333 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -62,11 +62,14 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "./providerStatusCache.ts"; -import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; +import type { + ProviderInstance, + ProviderWorkspaceSnapshot, +} from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; export type ProviderMaintenanceActionKind = "update"; diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index 5eb8842f5667..c62f93dae400 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -41,7 +41,7 @@ import { sha256 } from "@noble/hashes/sha2"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as ServerSettings from "../../serverSettings.ts"; import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 3ab2154b91fe..9c2cb395f307 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -29,7 +29,7 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { signalProcessGroup } from "../../process/processGroup.ts"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; import { collectSessionConfigOptionValues, diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 7a87854b19db..ea5aff4b4c77 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -16,7 +16,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; import { antigravityUserSkillDirectories, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 92d798945fc8..ffaf812e4c84 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -29,7 +29,7 @@ import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; -import type { AnyProviderDriver } from "./ProviderDriver.ts"; +import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; /** * Union of infrastructure services required to construct any built-in diff --git a/apps/server/src/provider/builtInProviderCatalog.ts b/apps/server/src/provider/builtInProviderCatalog.ts index 98336dbef0bf..115ad692ff5e 100644 --- a/apps/server/src/provider/builtInProviderCatalog.ts +++ b/apps/server/src/provider/builtInProviderCatalog.ts @@ -1,6 +1,6 @@ import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; import type * as Stream from "effect/Stream"; -import type { ServerProviderShape } from "./ServerProvider.ts"; +import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; export type ProviderSnapshotSource = { /** diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index 3fae307afb56..200aa6e713f2 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -25,7 +25,7 @@ import { clampPercent, makeUnavailableUsageLimits, makeUsageLimits, -} from "./providerUsageLimits.ts"; +} from "@t3tools/provider-core/server/usageLimits"; const SESSION_MINS = 5 * 60; const WEEK_MINS = 7 * 24 * 60; diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 32a7a1f07a9c..b32f701de330 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import type * as CodexErrors from "effect-codex-app-server/errors"; -import { clampPercent, makeUsageLimits } from "./providerUsageLimits.ts"; +import { clampPercent, makeUsageLimits } from "@t3tools/provider-core/server/usageLimits"; interface CodexRateLimitWindow { readonly usedPercent: number; diff --git a/apps/server/src/provider/cursorUsageLimits.ts b/apps/server/src/provider/cursorUsageLimits.ts index eeacd46a7d79..987eeda74850 100644 --- a/apps/server/src/provider/cursorUsageLimits.ts +++ b/apps/server/src/provider/cursorUsageLimits.ts @@ -13,7 +13,7 @@ import { clampPercent, makeUnavailableUsageLimits, makeUsageLimits, -} from "./providerUsageLimits.ts"; +} from "@t3tools/provider-core/server/usageLimits"; import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); diff --git a/apps/server/src/provider/grokUsageLimits.ts b/apps/server/src/provider/grokUsageLimits.ts index b0eaa6dc51f2..f200956b56b1 100644 --- a/apps/server/src/provider/grokUsageLimits.ts +++ b/apps/server/src/provider/grokUsageLimits.ts @@ -11,7 +11,7 @@ import { clampPercent, makeUnavailableUsageLimits, makeUsageLimits, -} from "./providerUsageLimits.ts"; +} from "@t3tools/provider-core/server/usageLimits"; const GrokCredentials = Schema.Record( Schema.String, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 17c1e442adb7..767ed192b25d 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, @@ -18,8 +19,10 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import * as ProviderHostLive from "./ProviderHostLive.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -133,8 +136,26 @@ function layerBackgroundPolicy(shouldRunScopeWork: boolean) { const layerBackgroundPolicyAlwaysRun = layerBackgroundPolicy(true); const layerBackgroundPolicyNeverRun = layerBackgroundPolicy(false); const layerServerSettingsTest = ServerSettings.layerTest(); -const layerAlwaysRunTest = Layer.merge(layerBackgroundPolicyAlwaysRun, layerServerSettingsTest); -const layerNeverRunTest = Layer.merge(layerBackgroundPolicyNeverRun, layerServerSettingsTest); +const layerServerConfigTest = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-managed-provider-test-", +}).pipe(Layer.provide(NodeServices.layer)); + +function layerProviderHost( + layerBackground: Layer.Layer, + layerSettings: Layer.Layer, +) { + return ProviderHostLive.layer.pipe( + Layer.provideMerge(layerBackground), + Layer.provideMerge(layerSettings), + Layer.provide(layerServerConfigTest), + ); +} + +const layerAlwaysRunTest = layerProviderHost( + layerBackgroundPolicyAlwaysRun, + layerServerSettingsTest, +); +const layerNeverRunTest = layerProviderHost(layerBackgroundPolicyNeverRun, layerServerSettingsTest); const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, @@ -326,7 +347,9 @@ describe("makeManagedServerProvider", () => { ), Effect.as(refreshedSnapshot), ), - }).pipe(Effect.provide(Layer.merge(layerBackgroundPolicyAlwaysRun, layerServerSettings))); + }).pipe( + Effect.provide(layerProviderHost(layerBackgroundPolicyAlwaysRun, layerServerSettings)), + ); yield* Deferred.await(initialCheckDone); const nextServerSettings = { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 389bf83f3f78..e2637cc1a146 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -15,10 +15,12 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; -import type { ServerProviderShape } from "./ServerProvider.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { + applyUsageLimitsUpdate, + resolveUsageLimitsAfterProbe, +} from "@t3tools/provider-core/server/usageLimits"; +import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; @@ -54,13 +56,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly refreshInterval?: Duration.Input; readonly refreshOnInterval?: boolean; readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; -}): Effect.fn.Return< - ServerProviderShape, - ServerSettingsError, - Scope.Scope | BackgroundPolicy.BackgroundPolicy | ServerSettings.ServerSettingsService -> { - const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; - const serverSettings = yield* ServerSettings.ServerSettingsService; +}): Effect.fn.Return { + const host = yield* ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -213,8 +210,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const state = yield* Ref.get(snapshotStateRef); const instanceId = state.snapshot.instanceId; const [genericDemand, instanceDemand] = yield* Effect.all([ - backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }), - backgroundPolicy.shouldRunScopeWork({ type: "provider-status", instanceId }), + host.shouldRunBackgroundWork({ type: "provider-status" }), + host.shouldRunBackgroundWork({ type: "provider-status", instanceId }), ]); return genericDemand || instanceDemand; }); @@ -222,7 +219,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const getRefreshInterval = input.refreshInterval !== undefined ? Effect.succeed(input.refreshInterval) - : serverSettings.getSettings.pipe( + : host.settings.get.pipe( Effect.map( (settings) => resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval, @@ -232,7 +229,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const refreshIntervalChanges = yield* Queue.sliding(1); if (input.refreshInterval === undefined) { - const serverSettingsChanges = yield* serverSettings.subscribeChanges; + const serverSettingsChanges = yield* host.settings.subscribe; yield* serverSettingsChanges.pipe( Stream.map((settings) => Duration.toMillis( diff --git a/apps/server/src/provider/museMaintenance.test.ts b/apps/server/src/provider/museMaintenance.test.ts index e3a3ea2f16ac..59d0868e768d 100644 --- a/apps/server/src/provider/museMaintenance.test.ts +++ b/apps/server/src/provider/museMaintenance.test.ts @@ -15,7 +15,7 @@ import { import { ProviderVersionCache, makeManualOnlyProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; const maintenanceCapabilities = { ...makeManualOnlyProviderMaintenanceCapabilities({ diff --git a/apps/server/src/provider/museMaintenance.ts b/apps/server/src/provider/museMaintenance.ts index 616c2dc06da5..0770b494a819 100644 --- a/apps/server/src/provider/museMaintenance.ts +++ b/apps/server/src/provider/museMaintenance.ts @@ -13,8 +13,8 @@ import { ProviderVersionCache, type ProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, -} from "./providerMaintenance.ts"; -import { parseGenericCliVersion } from "./providerSnapshot.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; const DRIVER = ProviderDriverKind.make("muse"); /** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/apps/server/src/provider/openCodeUsageLimits.ts index 80a44821ac8b..2192e75a7b3d 100644 --- a/apps/server/src/provider/openCodeUsageLimits.ts +++ b/apps/server/src/provider/openCodeUsageLimits.ts @@ -15,7 +15,7 @@ import { clampPercent, makeUnavailableUsageLimits, makeUsageLimits, -} from "./providerUsageLimits.ts"; +} from "@t3tools/provider-core/server/usageLimits"; const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 885ee191fb5d..979cacc75b14 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -30,10 +30,10 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { signalProcessGroup } from "../process/processGroup.ts"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; -import { collectStreamAsString } from "./providerSnapshot.ts"; +import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/apps/server/src/provider/opencodeVersionProbe.ts index d6f9f1bce8e1..aca8ab2e8189 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/apps/server/src/provider/opencodeVersionProbe.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/http"; import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import { parseGenericCliVersion } from "./providerSnapshot.ts"; +import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; export interface ProbedOpenCode { readonly generation: "v1" | "v2"; diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index c677e876de67..bc7291157e5d 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -12,8 +12,8 @@ import * as ServerConfig from "../config.ts"; import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "./ProviderDriver.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; import * as Schema from "effect/Schema"; import { diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index 61cfbf730976..d180b0e2dd99 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -15,7 +15,7 @@ import * as Stream from "effect/Stream"; import * as ServerSettings from "../serverSettings.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; -import type { ProviderInstance } from "./ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeProviderInstallation } from "./providerInstallation.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index 6c8fe697b8a0..d888725b3ba9 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -18,7 +18,7 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; -import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index b6580c78856d..a3c494e504b0 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -30,7 +30,7 @@ import { makeProviderMaintenanceCapabilities, ProviderVersionCache, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const CODEX_DRIVER = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index cc8b3728785f..5ef57660e01f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -34,9 +34,9 @@ import { resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, -} from "./providerMaintenance.ts"; -import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; +import type { ProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts index 30ad2bf8a286..f61061439730 100644 --- a/apps/server/src/provider/providerUpdateSettings.ts +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Stream from "effect/Stream"; -import type * as ServerSettingsModule from "../serverSettings.ts"; +import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; export interface ProviderSnapshotSettings { readonly provider: Settings; @@ -29,7 +29,7 @@ export function haveProviderSnapshotSettingsChanged( export function makeProviderSnapshotSettingsSource( provider: Settings, - serverSettings: ServerSettingsModule.ServerSettingsService["Service"], + settings: ProviderHostShape["settings"], ): { readonly getSettings: Effect.Effect, ServerSettingsError>; readonly streamSettings: Stream.Stream>; @@ -37,7 +37,7 @@ export function makeProviderSnapshotSettingsSource( const mapSettings = (settings: ServerSettings) => makeProviderSnapshotSettings(provider, settings); return { - getSettings: serverSettings.getSettings.pipe(Effect.map(mapSettings)), - streamSettings: serverSettings.streamChanges.pipe(Stream.map(mapSettings)), + getSettings: settings.get.pipe(Effect.map(mapSettings)), + streamSettings: settings.changes.pipe(Stream.map(mapSettings)), }; } diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index aa27bc94608c..41b5c9b9ca35 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -3,7 +3,7 @@ import type { ServerProvider } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; const makeProviderRegistryMock = ( providers: ReadonlyArray = [], diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts index de0c799a8cd3..2c296b4ff898 100644 --- a/apps/server/src/provider/unavailableProviderSnapshot.ts +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -18,7 +18,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { buildServerProvider } from "./providerSnapshot.ts"; +import { buildServerProvider } from "@t3tools/provider-core/server/snapshotProbe"; export interface UnavailableProviderSnapshotInput { readonly driverKind: ProviderDriverKind | string; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 18ff81af1c19..fbff83dfedb5 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -28,7 +28,7 @@ import { normalizeBitbucketPullRequestRecord, type NormalizedBitbucketPullRequestRecord, } from "./bitbucketPullRequests.ts"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index c9298efb9d1d..eb284af237c8 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -14,7 +14,7 @@ import { ChildProcessSpawner } from "effect/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import type { SourceControlProviderContext } from "./SourceControlProvider.ts"; const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 5f29e8b4053d..5a99cf68943b 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -10,7 +10,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index f1c149b18f2b..180d19ccee35 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -21,7 +21,7 @@ import { } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; -import { expandHomePathWith } from "../pathExpansion.ts"; +import { expandHomePathWith } from "@t3tools/provider-core/server/pathExpansion"; import { parseGitCloneProgressLine, type GitCloneProgressLine, diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 3c71a8849475..23b9e549e0de 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -63,7 +63,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; -import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; @@ -73,7 +73,7 @@ import { terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 27144025c0bf..2ff9cb659e8b 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -14,7 +14,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const ANTIGRAVITY_TIMEOUT_MS = 180_000; const MAX_OUTPUT_CHARS = 128_000; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0ee4b8763cfc..46e015123df2 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -19,7 +19,7 @@ import { SYNTHETIC_CLAUDE_THINKING_MODEL, } from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; -import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; import { writeFakeCli } from "../testUtils/fakeCli.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 5140ce3cdc30..25695f7fdefa 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -17,8 +17,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; -import { normalizeCliError, toJsonSchemaObject } from "./TextGenerationUtils.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { + normalizeCliError, + toJsonSchemaObject, +} from "@t3tools/provider-core/server/textGenerationUtils"; import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 25b4578bead6..0477c7fa163b 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -17,10 +17,13 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; -import { normalizeCliError, toJsonSchemaObject } from "./TextGenerationUtils.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { + normalizeCliError, + toJsonSchemaObject, +} from "@t3tools/provider-core/server/textGenerationUtils"; import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index a147d2f1ae45..fb796438158b 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -13,7 +13,7 @@ import { TextGenerationError, } from "@t3tools/contracts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; import type { CursorAuth } from "../provider/CursorAuth.ts"; diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 77f252c45d1e..6e14fa101ee2 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -9,7 +9,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, diff --git a/apps/server/src/textGeneration/MuseTextGeneration.ts b/apps/server/src/textGeneration/MuseTextGeneration.ts index 7b3b3e76ad67..e0d4f3f3afa5 100644 --- a/apps/server/src/textGeneration/MuseTextGeneration.ts +++ b/apps/server/src/textGeneration/MuseTextGeneration.ts @@ -26,13 +26,13 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; +} from "@t3tools/provider-core/server/textGenerationPrompts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, -} from "./TextGenerationUtils.ts"; +} from "@t3tools/provider-core/server/textGenerationUtils"; const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); const ItemNotification = Schema.Struct({ diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts index e0557a3a1317..f6ceb7252a8e 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -19,7 +19,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const isTextGenerationError = Schema.is(TextGenerationError); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b47a020715c3..5ffcf4566185 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,7 +6,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index e4d56d0d5271..7d52e4e08e65 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -17,7 +17,7 @@ import { buildPiRpcLaunch, resolvePiLaunchArgs, } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; -import * as TextGenerationOperations from "./TextGenerationOperations.ts"; +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const PI_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index ff95b780c31d..61baaebe4dc2 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -8,12 +8,12 @@ import { describe, expect } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as TextGeneration from "./TextGeneration.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as Layer from "effect/Layer"; -import { buildThreadTitlePrompt } from "./TextGenerationPrompts.ts"; +import { buildThreadTitlePrompt } from "@t3tools/provider-core/server/textGenerationPrompts"; const makeStubTextGeneration = ( overrides: Partial, diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index f6ef94290db9..ee6f18e7b217 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,119 +1,32 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { - BranchNamingOptions, - ChatAttachment, - ModelSelection, - ProviderInstanceId, -} from "@t3tools/contracts"; +import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ThreadTitleLinks from "./ThreadTitleLinks.ts"; -import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export interface CommitMessageGenerationInput { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - /** When true, the model also returns a semantic branch name for the change. */ - includeBranch?: boolean; - policy?: TextGenerationPolicy | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface CommitMessageGenerationResult { - subject: string; - body: string; - /** Only present when `includeBranch` was set on the input. */ - branch?: string | undefined; -} - -export interface PrContentGenerationInput { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - changeRequestTemplate?: string | undefined; - policy?: TextGenerationPolicy | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface PrContentGenerationResult { - title: string; - body: string; -} - -export interface BranchNameGenerationInput { - naming?: BranchNamingOptions | undefined; - cwd: string; - message: string; - attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface BranchNameGenerationResult { - branch: string; -} - -export interface ThreadTitleGenerationInput { - linkedContext?: string | undefined; - cwd: string; - message: string; - /** Present when replacing an existing title from the current thread history. */ - previousTitle?: string | undefined; - attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface ThreadTitleGenerationResult { - title: string; - needsRefinement?: boolean | undefined; -} +export type { + BranchNameGenerationInput, + BranchNameGenerationResult, + CommitMessageGenerationInput, + CommitMessageGenerationResult, + PrContentGenerationInput, + PrContentGenerationResult, + ThreadTitleGenerationInput, + ThreadTitleGenerationResult, +} from "@t3tools/provider-core/server/textGeneration"; /** * TextGeneration - Service tag for commit and change request text generation. */ -export class TextGeneration extends Context.Service< - TextGeneration, - { - /** - * Generate a commit message from staged change context. - */ - readonly generateCommitMessage: ( - input: CommitMessageGenerationInput, - ) => Effect.Effect; - - /** - * Generate change request title/body from branch and diff context. - */ - readonly generatePrContent: ( - input: PrContentGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise branch name from a user message. - */ - readonly generateBranchName: ( - input: BranchNameGenerationInput, - ) => Effect.Effect; - - /** Generate a concise thread title from a first message or thread history. */ - readonly generateThreadTitle: ( - input: ThreadTitleGenerationInput, - ) => Effect.Effect; - } ->()("t3/textGeneration/TextGeneration") {} +export class TextGeneration extends Context.Service()( + "t3/textGeneration/TextGeneration", +) {} type TextGenerationOp = | "generateCommitMessage" diff --git a/apps/server/src/textGeneration/TextGenerationOperations.ts b/apps/server/src/textGeneration/TextGenerationOperations.ts deleted file mode 100644 index b1fc905cae06..000000000000 --- a/apps/server/src/textGeneration/TextGenerationOperations.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * The text generation operations every provider shares. A provider supplies a - * runner that sends one prompt and decodes the reply; prompts, linked source - * control context, and output sanitizing live here so providers cannot drift - * on them. - * - * @module textGeneration/TextGenerationOperations - */ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; -import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; - -import type * as TextGeneration from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "./TextGenerationUtils.ts"; - -export type Operation = keyof TextGeneration.TextGeneration["Service"]; - -/** One prompt for a provider to run. */ -export interface Request { - readonly operation: Operation; - readonly cwd: string; - readonly prompt: string; - readonly outputSchema: S; - readonly modelSelection: ModelSelection; - /** The user's attachments, already listed in the prompt. Providers that accept images send them. */ - readonly attachments?: ReadonlyArray | undefined; -} - -/** Runs one request and decodes the reply as its `outputSchema`. */ -export type Runner = ( - request: Request, -) => Effect.Effect; - -/** Decodes the JSON object in a text reply, which models often wrap in prose. */ -export const decodeJsonReply = ( - request: Pick, "operation" | "outputSchema">, - provider: string, - text: string, -) => { - // Each request builds its own output schema, so the decoder cannot be hoisted. - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(request.outputSchema)); - return decodeOutput(extractJsonObject(text)).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: request.operation, - detail: `${provider} returned invalid structured output.`, - cause, - }), - ), - ); -}; - -/** The text generation service over `run`. `name` prefixes each operation's span. */ -export function fromRunner(name: string, run: Runner): TextGeneration.TextGeneration["Service"] { - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn(`${name}.generateCommitMessage`)(function* (input) { - const generated = yield* run({ - operation: "generateCommitMessage", - cwd: input.cwd, - modelSelection: input.modelSelection, - ...buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - policy: input.policy, - }), - }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn(`${name}.generatePrContent`)(function* (input) { - const generated = yield* run({ - operation: "generatePrContent", - cwd: input.cwd, - modelSelection: input.modelSelection, - ...buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - policy: input.policy, - changeRequestTemplate: input.changeRequestTemplate, - }), - }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; - }); - - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn(`${name}.generateBranchName`)(function* (input) { - const generated = yield* run({ - operation: "generateBranchName", - cwd: input.cwd, - modelSelection: input.modelSelection, - attachments: input.attachments, - ...buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - naming: input.naming, - }), - }); - return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn(`${name}.generateThreadTitle`)(function* (input) { - const generated = yield* run({ - operation: "generateThreadTitle", - cwd: input.cwd, - modelSelection: input.modelSelection, - attachments: input.attachments, - ...buildThreadTitlePrompt({ - message: input.message, - previousTitle: input.previousTitle, - linkedContext: input.linkedContext, - attachments: input.attachments, - }), - }); - return { - title: sanitizeThreadTitle(generated.title), - ...(generated.needsRefinement ? { needsRefinement: true } : {}), - }; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; -} diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts index 5fab9219080f..b53ecc543597 100644 --- a/apps/server/src/textGeneration/TextGenerationPresets.ts +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -1,4 +1,4 @@ -import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; +import type { TextGenerationPolicy } from "@t3tools/provider-core/server/textGenerationPolicy"; export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { kind: "conventional_commits", diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c06c02515898..4ff2bbce9ef6 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -54,11 +54,11 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { writeFileStringAtomically } from "../atomicWrite.ts"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; -import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; import { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 3804b9649105..c50ebb57f40e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { codexPlanLabel } from "../provider/CodexProvider.ts"; import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; -import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts"; +import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; const AuthFile = Schema.Struct({ id: Schema.String, diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index d575f944d885..1a39c754b165 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -23,7 +23,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; -import { expandHomePathWith } from "../pathExpansion.ts"; +import { expandHomePathWith } from "@t3tools/provider-core/server/pathExpansion"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 29c00e2b3da6..82dedf3b159a 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -14,7 +14,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { expandHomePathWith } from "../pathExpansion.ts"; +import { expandHomePathWith } from "@t3tools/provider-core/server/pathExpansion"; export class WorkspaceRootNotExistsError extends Schema.TaggedError()( "WorkspaceRootNotExistsError", diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index 5d4652393747..2496a593fd2d 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -1,6 +1,6 @@ import type * as Path from "effect/Path"; -import { expandHomePathWith } from "./pathExpansion.ts"; +import { expandHomePathWith } from "@t3tools/provider-core/server/pathExpansion"; /** * Directory new worktrees are created under: the `worktreesDirectory` diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a11852ecbc5..5e2dfc6b5dc7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,7 +121,7 @@ import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; -import * as IdAllocator from "./orchestration-v2/IdAllocator.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import { @@ -168,7 +168,7 @@ import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.t import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; +import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; diff --git a/apps/web/package.json b/apps/web/package.json index 20e180aad46b..53547e6cfc22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 04d32f610855..514f379d9f33 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -4,7 +4,7 @@ import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; import { Button } from "../ui/button"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; import { ComposerBanner } from "./ComposerBanner"; @@ -13,7 +13,7 @@ import type { ComposerBannerStackItem } from "./ComposerBannerStack"; /** Driver name, then the instance when there could be more than one of that driver. */ function accountLabel(account: UsageLimitsReport["accounts"][number]): string { if (!account.instanceId) return account.label; - const driver = getDriverOption(account.driver)?.label ?? String(account.driver); + const driver = providerClients.get(account.driver)?.label ?? String(account.driver); const instance = account.displayName?.trim() || (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx index 8b052d76168a..79d10c3c6bb2 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx @@ -132,7 +132,9 @@ vi.mock("../settings/CodexSetupSection", () => ({ CodexSetupSection: () => null, AddManagedCodexAccountDialog: () => null, })); -vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Agent" }) })); +vi.mock("../settings/providerDriverMeta", () => ({ + providerClients: { get: () => ({ label: "Agent" }) }, +})); vi.mock("../settings/providerStatus", () => ({ getProviderSummary: () => ({ headline: "Checking", detail: null }), })); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx index cf7c38ec5875..0a3ae9abc311 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx @@ -119,7 +119,7 @@ vi.mock("../settings/CodexSetupSection", () => ({ AddManagedCodexAccountDialog: () => null, })); vi.mock("../settings/providerDriverMeta", () => ({ - getDriverOption: (driver: string) => ({ label: driver }), + providerClients: { get: (driver: string) => ({ label: driver }) }, })); vi.mock("../settings/providerStatus", () => ({ getProviderSummary: () => ({ headline: "Setup required" }), diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index e94078271799..9a4549efc9fa 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -74,7 +74,7 @@ import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { connectPairing } from "../../connection/onboarding"; import { getProviderSummary } from "../settings/providerStatus"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; @@ -969,7 +969,7 @@ function AgentCard({ readonly terminalAvailable: boolean; readonly onOpenTerminal: () => void; }) { - const meta = getDriverOption(ProviderDriverKind.make(driver)); + const meta = providerClients.get(ProviderDriverKind.make(driver)); const displayName = provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver)); const summary = getProviderSummary(provider); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index b329d6ef134b..e923a081b6e4 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -33,7 +33,7 @@ import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; -import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { SettingsGroup } from "./SettingsGroup"; import { SettingsRow } from "./settingsLayout"; @@ -81,7 +81,7 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); -const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; +const DEFAULT_DRIVER_OPTION = providerClients.definitions[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; /** * Validate an instance id against the same slug rules the server applies in @@ -149,7 +149,7 @@ export function AddProviderInstanceDialog({ return ids; }, [settings.providerInstances, settings.providers]); - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; const defaultIdentity: ProviderIdentityDraft = { label: driverOption.label, @@ -433,16 +433,17 @@ export function AddProviderInstanceDialog({ aria-labelledby="add-instance-driver-label" className="grid grid-cols-1 sm:grid-cols-2" > - {DRIVER_OPTIONS.filter((option) => option.value !== ACP_REGISTRY_DRIVER_KIND).map( - (option) => { + {providerClients.definitions + .filter((option) => option.driverKind !== ACP_REGISTRY_DRIVER_KIND) + .map((option) => { return ( @@ -462,8 +463,7 @@ export function AddProviderInstanceDialog({ ) : null} ); - }, - )} + })}

diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index eab2fba04cb2..e147dd2cb447 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -46,7 +46,10 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import type { DriverOption, ProviderEnvironmentFieldDefinition } from "./providerDriverMeta"; +import type { + ProviderClientDefinition, + ProviderEnvironmentField, +} from "@t3tools/provider-core/client"; import { deriveProviderSettingsFields, ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -216,7 +219,7 @@ export function providerEnvironmentWithoutNames( export function nextProviderEnvironmentWithFieldValue( environment: ReadonlyArray | undefined, - field: ProviderEnvironmentFieldDefinition, + field: ProviderEnvironmentField, value: string, ): ReadonlyArray { const trimmed = value.trim(); @@ -250,11 +253,11 @@ export function nextProviderEnvironmentWithFieldValue( } function ProviderEnvironmentFieldRow(props: { - readonly field: ProviderEnvironmentFieldDefinition; + readonly field: ProviderEnvironmentField; readonly variable: ProviderInstanceEnvironmentVariable | undefined; readonly idPrefix: string; - readonly onCommit: (field: ProviderEnvironmentFieldDefinition, value: string) => void; - readonly onRemove: (field: ProviderEnvironmentFieldDefinition) => void; + readonly onCommit: (field: ProviderEnvironmentField, value: string) => void; + readonly onRemove: (field: ProviderEnvironmentField) => void; }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); @@ -472,7 +475,7 @@ export function ProviderEnvironmentSection(props: { interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; - readonly driverOption: DriverOption | undefined; + readonly driverOption: ProviderClientDefinition | undefined; readonly liveProvider: ServerProvider | undefined; readonly mode: "list" | "editor"; readonly selected?: boolean | undefined; @@ -719,10 +722,10 @@ export function ProviderInstanceCard({ ); updateEnvironment([...dedicatedEnvironment, ...environment]); }; - const updateEnvironmentField = (field: ProviderEnvironmentFieldDefinition, value: string) => { + const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => { updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value)); }; - const removeEnvironmentField = (field: ProviderEnvironmentFieldDefinition) => { + const removeEnvironmentField = (field: ProviderEnvironmentField) => { updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name]))); }; diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 6edeedb30740..ff1de91addc9 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, @@ -9,7 +9,7 @@ import { describe("ProviderSettingsForm helpers", () => { it("derives visible provider config fields from the client definition schema", () => { - const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")]; + const codex = providerClients.get(ProviderDriverKind.make("codex")); expect(codex).toBeDefined(); expect(deriveProviderSettingsFields(codex!).map((field) => field.key)).toEqual([ @@ -21,7 +21,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("sources labels and descriptions from schema annotations", () => { - const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; + const opencode = providerClients.get(ProviderDriverKind.make("opencode")); expect(opencode).toBeDefined(); const serverPassword = deriveProviderSettingsFields(opencode!).find( @@ -36,7 +36,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("uses a dedicated environment field instead of legacy Cursor CLI settings", () => { - const cursor = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("cursor")]; + const cursor = providerClients.get(ProviderDriverKind.make("cursor")); expect(cursor).toBeDefined(); expect(deriveProviderSettingsFields(cursor!)).toEqual([]); @@ -52,7 +52,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("exposes ACP Registry as an instance-only configurable driver", () => { - const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); expect(acpRegistry).toBeDefined(); expect(acpRegistry?.hasDefaultInstance).toBe(false); @@ -65,14 +65,14 @@ describe("ProviderSettingsForm helpers", () => { }); it("shows the local executable without registry identity or authentication fields", () => { - const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); expect( deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), ).toEqual(["source", "commandPath"]); }); it("derives a select control with its choices for the Antigravity sign-in method", () => { - const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; + const antigravity = providerClients.get(ProviderDriverKind.make("antigravity")); expect(antigravity).toBeDefined(); const fields = deriveProviderSettingsFields(antigravity!); @@ -95,7 +95,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("shows the auto-compaction threshold for Claude providers", () => { - const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent")); expect(claude).toBeDefined(); expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ @@ -107,7 +107,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("preserves unknown config keys while omitting empty configurable fields", () => { - const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; + const opencode = providerClients.get(ProviderDriverKind.make("opencode")); expect(opencode).toBeDefined(); const serverUrl = deriveProviderSettingsFields(opencode!).find( diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index dfb35facbd60..d772585c74f3 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -18,7 +18,7 @@ import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; -import type { ProviderClientDefinition } from "./providerDriverMeta"; +import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { @@ -81,7 +81,8 @@ export function deriveProviderSettingsFields( value?: unknown, ): ReadonlyArray { const isLocalAcp = - definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; + definition.driverKind === "acpRegistry" && + readProviderConfigString(value, "source") === "local"; const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -537,7 +538,8 @@ export function ProviderSettingsForm({ [definition, value], ); const isLocalAcp = - definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; + definition.driverKind === "acpRegistry" && + readProviderConfigString(value, "source") === "local"; if (fields.length === 0) { return null; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 37b986217d29..dd471b4d14e8 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -95,7 +95,7 @@ import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetup import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; import { readCodexSetupMode } from "./CodexSetupSection.logic"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; import { backgroundActivityOverrideSettings, @@ -145,8 +145,8 @@ function providerConfigString(config: unknown, key: string): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, +const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ + provider: definition.driverKind, })); function configuredBinaryPath(config: unknown): string { @@ -975,7 +975,7 @@ export function EnvironmentProviderSettings({ }; const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => { - const driverOption = getDriverOption(row.driver); + const driverOption = providerClients.get(row.driver); const liveProvider = serverProviders.find( (candidate) => candidate.instanceId === row.instanceId, ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index c576082fd025..60d2d346d13b 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -10,56 +10,22 @@ import { MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; -import type * as Schema from "effect/Schema"; +import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; -type ProviderSettingsSchema = { - readonly fields: Readonly>; -} & Schema.Top; - -/** - * Browser-safe provider definition. This is deliberately shaped like the - * future provider package client export: the core web app gets a schema with - * field annotations plus provider-level presentation metadata, then renders - * settings generically. - */ -export interface ProviderClientDefinition { - readonly value: ProviderDriverKind; - readonly label: string; - readonly settingsSchema: ProviderSettingsSchema; - readonly environmentFields?: readonly ProviderEnvironmentFieldDefinition[]; - /** Whether this driver has a built-in default instance backed by legacy settings. */ - readonly hasDefaultInstance?: boolean; - /** - * Optional short label rendered as a `variant="warning"` badge next to - * the instance title. Used to flag drivers that still ship under an - * early-access or preview gate — the flag is a property of the driver - * kind (not a specific instance), so every instance of that driver — - * built-in default or custom — advertises the same marker. - */ - readonly badgeLabel?: string; -} - -export interface ProviderEnvironmentFieldDefinition { - readonly name: string; - readonly label: string; - readonly description?: string; - readonly placeholder?: string; - readonly sensitive?: boolean; -} - -const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +/** The provider client definitions this web build ships, in presentation order. */ +export const providerClients = makeProviderClientRegistry([ { - value: ProviderDriverKind.make("codex"), + driverKind: ProviderDriverKind.make("codex"), label: "Codex", settingsSchema: CodexSettings, }, { - value: ProviderDriverKind.make("claudeAgent"), + driverKind: ProviderDriverKind.make("claudeAgent"), label: "Claude", settingsSchema: ClaudeSettings, }, { - value: ProviderDriverKind.make("cursor"), + driverKind: ProviderDriverKind.make("cursor"), label: "Cursor", settingsSchema: CursorSettings, environmentFields: [ @@ -73,55 +39,35 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ ], }, { - value: ProviderDriverKind.make("grok"), + driverKind: ProviderDriverKind.make("grok"), label: "Grok", settingsSchema: GrokSettings, }, { - value: ProviderDriverKind.make("opencode"), + driverKind: ProviderDriverKind.make("opencode"), label: "OpenCode", settingsSchema: OpenCodeSettings, }, { - value: ProviderDriverKind.make("antigravity"), + driverKind: ProviderDriverKind.make("antigravity"), label: "Antigravity", settingsSchema: AntigravitySettings, }, { - value: ProviderDriverKind.make("muse"), + driverKind: ProviderDriverKind.make("muse"), label: "Muse Code", settingsSchema: MuseSettings, badgeLabel: "Beta", }, { - value: ProviderDriverKind.make("pi"), + driverKind: ProviderDriverKind.make("pi"), label: "Pi", settingsSchema: PiSettings, }, { - value: ProviderDriverKind.make("acpRegistry"), + driverKind: ProviderDriverKind.make("acpRegistry"), label: "ACP Registry", settingsSchema: AcpRegistrySettings, hasDefaultInstance: false, }, -]; - -const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< - Record -> = Object.fromEntries( - PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), -); - -export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS; -export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE; -export type DriverOption = ProviderClientDefinition; - -/** - * Look up the driver metadata for an instance's `driver` field. Accepts - * Returns `undefined` for fork / unknown drivers so callers can decide how - * to render them — typically by falling back to a generic card. - */ -export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined { - if (driver === undefined) return undefined; - return PROVIDER_CLIENT_DEFINITION_BY_VALUE[driver]; -} +]); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 9ebe499179b0..be48577555aa 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -21,7 +21,7 @@ import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; @@ -81,7 +81,9 @@ function AccountAvatar({ - {getDriverOption(account.driver)?.label ?? String(account.driver)} + {providerClients.get(account.driver)?.label ?? String(account.driver)} ); } @@ -166,7 +168,7 @@ function SegmentPopover({ - {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + {account.displayName ?? providerClients.get(account.driver)?.label ?? account.driver} {account.email ? ( @@ -531,7 +533,7 @@ function PoolWindowCard({ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); - const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + const label = providerClients.get(pool.driver)?.label ?? String(pool.driver); const windows = displayLimitWindows(pool); return (
diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index bbe53e36936a..ff19e20fc84d 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -91,7 +91,9 @@ vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); -vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); +vi.mock("../settings/providerDriverMeta", () => ({ + providerClients: { get: () => ({ label: "Codex" }) }, +})); import { UsagePage } from "./UsagePage"; diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 3184f5a12669..d11c0e367d1f 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -13,7 +13,7 @@ whether a turn runs. This page lists the decisions and evidence a new driver nee [shared ACP adapter](../../apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts), like Grok and Antigravity. Never add agent-id checks to the generic registry adapter. - **Other protocols** get a native adapter that implements - [`ProviderAdapterV2`](../../apps/server/src/orchestration-v2/ProviderAdapter.ts), like Codex, + [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex, Claude, Cursor, OpenCode, Pi, and Muse. Provider-specific behavior stays in the adapter and driver. Orchestration and clients read diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 912cd3045531..3da603d9ce01 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -2,7 +2,7 @@ Orchestration records intent and state without knowing which provider runs a thread. Provider protocols, account ownership, permissions, and capabilities belong at the -[adapter boundary](../../apps/server/src/orchestration-v2/ProviderAdapter.ts). Normalize there +[adapter boundary](../../packages/provider-core/src/server/ProviderAdapter.ts). Normalize there instead of spreading provider checks through orchestration and clients. A driver kind identifies an integration; an instance identifies one configuration and account diff --git a/package.json b/package.json index 559801c550c7..078da2b29452 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json new file mode 100644 index 000000000000..23ac2a66e846 --- /dev/null +++ b/packages/provider-core/package.json @@ -0,0 +1,138 @@ +{ + "name": "@t3tools/provider-core", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./server/adapterDriver": { + "types": "./src/server/adapterDriver.ts", + "import": "./src/server/adapterDriver.ts" + }, + "./server/auth": { + "types": "./src/server/auth.ts", + "import": "./src/server/auth.ts" + }, + "./server/collectStreamText": { + "types": "./src/server/collectStreamText.ts", + "import": "./src/server/collectStreamText.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/errors": { + "types": "./src/server/errors.ts", + "import": "./src/server/errors.ts" + }, + "./server/failure": { + "types": "./src/server/failure.ts", + "import": "./src/server/failure.ts" + }, + "./server/IdAllocator": { + "types": "./src/server/IdAllocator.ts", + "import": "./src/server/IdAllocator.ts" + }, + "./server/instanceEnvironment": { + "types": "./src/server/instanceEnvironment.ts", + "import": "./src/server/instanceEnvironment.ts" + }, + "./server/maintenance": { + "types": "./src/server/maintenance.ts", + "import": "./src/server/maintenance.ts" + }, + "./server/maintenanceResolver": { + "types": "./src/server/maintenanceResolver.ts", + "import": "./src/server/maintenanceResolver.ts" + }, + "./server/mcpSession": { + "types": "./src/server/mcpSession.ts", + "import": "./src/server/mcpSession.ts" + }, + "./server/mcpToolPresentation": { + "types": "./src/server/mcpToolPresentation.ts", + "import": "./src/server/mcpToolPresentation.ts" + }, + "./server/orchestrationInstructions": { + "types": "./src/server/orchestrationInstructions.ts", + "import": "./src/server/orchestrationInstructions.ts" + }, + "./server/pathExpansion": { + "types": "./src/server/pathExpansion.ts", + "import": "./src/server/pathExpansion.ts" + }, + "./server/processGroup": { + "types": "./src/server/processGroup.ts", + "import": "./src/server/processGroup.ts" + }, + "./server/ProviderAdapter": { + "types": "./src/server/ProviderAdapter.ts", + "import": "./src/server/ProviderAdapter.ts" + }, + "./server/ProviderHost": { + "types": "./src/server/ProviderHost.ts", + "import": "./src/server/ProviderHost.ts" + }, + "./server/randomUuid": { + "types": "./src/server/randomUuid.ts", + "import": "./src/server/randomUuid.ts" + }, + "./server/selectionTransition": { + "types": "./src/server/selectionTransition.ts", + "import": "./src/server/selectionTransition.ts" + }, + "./server/snapshot": { + "types": "./src/server/snapshot.ts", + "import": "./src/server/snapshot.ts" + }, + "./server/snapshotProbe": { + "types": "./src/server/snapshotProbe.ts", + "import": "./src/server/snapshotProbe.ts" + }, + "./server/textGeneration": { + "types": "./src/server/textGeneration.ts", + "import": "./src/server/textGeneration.ts" + }, + "./server/textGenerationOperations": { + "types": "./src/server/textGenerationOperations.ts", + "import": "./src/server/textGenerationOperations.ts" + }, + "./server/textGenerationPolicy": { + "types": "./src/server/textGenerationPolicy.ts", + "import": "./src/server/textGenerationPolicy.ts" + }, + "./server/textGenerationPrompts": { + "types": "./src/server/textGenerationPrompts.ts", + "import": "./src/server/textGenerationPrompts.ts" + }, + "./server/textGenerationUtils": { + "types": "./src/server/textGenerationUtils.ts", + "import": "./src/server/textGenerationUtils.ts" + }, + "./server/threadTitleContext": { + "types": "./src/server/threadTitleContext.ts", + "import": "./src/server/threadTitleContext.ts" + }, + "./server/usageLimits": { + "types": "./src/server/usageLimits.ts", + "import": "./src/server/usageLimits.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-core/src/client.test.ts b/packages/provider-core/src/client.test.ts new file mode 100644 index 000000000000..25fa59325de1 --- /dev/null +++ b/packages/provider-core/src/client.test.ts @@ -0,0 +1,34 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { defineProviderClient, makeProviderClientRegistry } from "./client.ts"; + +const settingsSchema = Schema.Struct({ binaryPath: Schema.String }); + +describe("makeProviderClientRegistry", () => { + it("looks definitions up by driver kind and keeps unknown drivers undefined", () => { + const codex = defineProviderClient({ + driverKind: ProviderDriverKind.make("codex"), + label: "Codex", + settingsSchema, + }); + const registry = makeProviderClientRegistry([codex]); + + expect(registry.get(ProviderDriverKind.make("codex"))).toBe(codex); + expect(registry.get(ProviderDriverKind.make("forkDriver"))).toBeUndefined(); + expect(registry.get(undefined)).toBeUndefined(); + }); + + it("rejects two definitions for one driver kind", () => { + const definition = defineProviderClient({ + driverKind: ProviderDriverKind.make("codex"), + label: "Codex", + settingsSchema, + }); + + expect(() => makeProviderClientRegistry([definition, definition])).toThrow( + "Provider driver 'codex' is defined more than once.", + ); + }); +}); diff --git a/packages/provider-core/src/client.ts b/packages/provider-core/src/client.ts new file mode 100644 index 000000000000..18c64cede595 --- /dev/null +++ b/packages/provider-core/src/client.ts @@ -0,0 +1,77 @@ +/** + * The client half of a provider package. + * + * Every provider package exports a `ProviderClientDefinition` from its + * `./client` entry point. Clients render settings, labels, and badges from + * these definitions plus the server's `ServerProvider` snapshots, so a new + * provider needs no per-driver branches in web or mobile. + * + * Definitions are browser- and React Native-safe: plain data and Effect + * schemas, no platform UI imports. + * + * @module provider-core/client + */ +import type { ProviderDriverKind } from "@t3tools/contracts"; +import type * as Schema from "effect/Schema"; + +/** + * A provider's instance settings schema. Fields carry `providerSettingsForm` + * annotations, which the settings form renders generically. + */ +export type ProviderSettingsSchema = { + readonly fields: Readonly>; +} & Schema.Top; + +/** An environment variable the settings UI offers for every instance of a driver. */ +export interface ProviderEnvironmentField { + readonly name: string; + readonly label: string; + readonly description?: string; + readonly placeholder?: string; + readonly sensitive?: boolean; +} + +export interface ProviderClientDefinition { + readonly driverKind: ProviderDriverKind; + readonly label: string; + readonly settingsSchema: ProviderSettingsSchema; + readonly environmentFields?: ReadonlyArray; + /** Whether the driver has a default instance backed by legacy `providers.` settings. */ + readonly hasDefaultInstance?: boolean; + /** + * Short warning badge beside every instance of the driver, such as "Beta". + * It marks the driver kind, so built-in and custom instances show it alike. + */ + readonly badgeLabel?: string; +} + +export function defineProviderClient( + definition: Definition, +): Definition { + return definition; +} + +/** The provider definitions a client loaded, in presentation order. */ +export interface ProviderClientRegistry { + readonly definitions: ReadonlyArray; + /** `undefined` for drivers this client does not ship, such as a fork's driver. */ + readonly get: ( + driverKind: ProviderDriverKind | undefined, + ) => ProviderClientDefinition | undefined; +} + +export function makeProviderClientRegistry( + definitions: ReadonlyArray, +): ProviderClientRegistry { + const byDriverKind = new Map(); + for (const definition of definitions) { + if (byDriverKind.has(definition.driverKind)) { + throw new Error(`Provider driver '${definition.driverKind}' is defined more than once.`); + } + byDriverKind.set(definition.driverKind, definition); + } + return { + definitions, + get: (driverKind) => (driverKind === undefined ? undefined : byDriverKind.get(driverKind)), + }; +} diff --git a/apps/server/src/orchestration-v2/IdAllocator.ts b/packages/provider-core/src/server/IdAllocator.ts similarity index 99% rename from apps/server/src/orchestration-v2/IdAllocator.ts rename to packages/provider-core/src/server/IdAllocator.ts index f619e45095c0..cb31cfb5895f 100644 --- a/apps/server/src/orchestration-v2/IdAllocator.ts +++ b/packages/provider-core/src/server/IdAllocator.ts @@ -26,7 +26,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { randomUuidV4 } from "./RandomUuid.ts"; +import { randomUuidV4 } from "./randomUuid.ts"; export const IdAllocatorV2Kind = Schema.Literals([ "command", @@ -186,7 +186,7 @@ export interface IdAllocatorV2Shape { } export class IdAllocatorV2 extends Context.Service()( - "t3/orchestration-v2/IdAllocator/IdAllocatorV2", + "@t3tools/provider-core/server/IdAllocator/IdAllocatorV2", ) {} const encodePart = (part: string | number): string => encodeURIComponent(String(part)); diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts similarity index 99% rename from apps/server/src/orchestration-v2/ProviderAdapter.ts rename to packages/provider-core/src/server/ProviderAdapter.ts index 298bbf9a9fe1..3cc27acd6d5e 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -42,7 +42,7 @@ import type * as Stream from "effect/Stream"; import type { ProviderSelectionTransitionInput, ProviderSelectionTransitionPlan, -} from "./ProviderSelectionTransition.ts"; +} from "./selectionTransition.ts"; export const ProviderAdapterV2RuntimePolicy = Schema.Struct({ runtimeMode: RuntimeMode, @@ -641,5 +641,5 @@ export interface ProviderAdapterV2Shape { } export class ProviderAdapterV2 extends Context.Service()( - "t3/orchestration-v2/ProviderAdapter/ProviderAdapterV2", + "@t3tools/provider-core/server/ProviderAdapter/ProviderAdapterV2", ) {} diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts new file mode 100644 index 000000000000..f0a695da43d4 --- /dev/null +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -0,0 +1,54 @@ +/** + * ProviderHost — what a provider package may ask of the server it runs in. + * + * Drivers and adapters run inside a T3 server but must not import it. The + * server provides this one service; everything a provider needs from its + * environment (directories, server settings, background demand, attachment + * files) goes through it, so a provider package depends only on + * `@t3tools/provider-core` and its own protocol code. + * + * @module provider-core/server/host + */ +import type { + BackgroundScope, + ChatAttachment, + ServerSettings, + ServerSettingsError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +export interface ProviderHostPaths { + /** Directory the server was started in; the fallback cwd for sessions without a project. */ + readonly cwd: string; + /** T3 home. */ + readonly baseDir: string; + /** Server-owned state directory under the T3 home. */ + readonly stateDir: string; + /** Scratch space for provider probes and generated helper files. */ + readonly providerStatusCacheDir: string; +} + +export interface ProviderHostShape { + readonly paths: ProviderHostPaths; + readonly settings: { + readonly get: Effect.Effect; + /** Every settings change after subscription, starting with the next one. */ + readonly changes: Stream.Stream; + /** Changes buffered from the moment the scoped subscription is acquired. */ + readonly subscribe: Effect.Effect, never, Scope.Scope>; + }; + /** Whether background work for `scope` should run now (client demand and host power). */ + readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; + /** + * Absolute path of a stored chat attachment, or `null` when its id does not + * resolve inside the attachments directory. + */ + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +} + +export class ProviderHost extends Context.Service()( + "@t3tools/provider-core/server/ProviderHost", +) {} diff --git a/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts b/packages/provider-core/src/server/adapterDriver.ts similarity index 94% rename from apps/server/src/orchestration-v2/ProviderAdapterDriver.ts rename to packages/provider-core/src/server/adapterDriver.ts index 765abd21af0f..6a2e57daab0b 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterDriver.ts +++ b/packages/provider-core/src/server/adapterDriver.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import type * as Effect from "effect/Effect"; import type * as Scope from "effect/Scope"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; export class ProviderAdapterDriverCreateError extends Schema.TaggedError()( "ProviderAdapterDriverCreateError", diff --git a/packages/provider-core/src/server/auth.ts b/packages/provider-core/src/server/auth.ts new file mode 100644 index 000000000000..9954249b6796 --- /dev/null +++ b/packages/provider-core/src/server/auth.ts @@ -0,0 +1,57 @@ +import type { + ChatGptReconnectProfile, + ChatGptTransferredProfile, + ProviderAuthRespondInput, + ProviderAuthState, + ProviderSetupError, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import type * as Stream from "effect/Stream"; + +export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly reconnectProfile?: ( + methodId: string, + ) => Effect.Effect; + readonly importProfile?: ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly adoptCredentials?: ( + update: Effect.Effect, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; + readonly start: ( + ownerSessionId: string, + stopSessions?: Effect.Effect, + methodId?: string, + returnUrl?: string, + callbackMode?: "server" | "client", + ) => Effect.Effect; + readonly complete: ( + ownerSessionId: string, + input: { readonly flowId: string; readonly callbackUrl: string }, + ) => Effect.Effect; + readonly cancel: ( + ownerSessionId: string, + flowId: string, + ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; + /** The controller closes process admission before it stops routed sessions. */ + readonly logout: ( + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly subscribe: (ownerSessionId: string) => Stream.Stream; + readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; +} diff --git a/apps/server/src/stream/collectUint8StreamText.test.ts b/packages/provider-core/src/server/collectStreamText.test.ts similarity index 96% rename from apps/server/src/stream/collectUint8StreamText.test.ts rename to packages/provider-core/src/server/collectStreamText.test.ts index 4a41cf11ec68..c36bfcf3b9fc 100644 --- a/apps/server/src/stream/collectUint8StreamText.test.ts +++ b/packages/provider-core/src/server/collectStreamText.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; -import { collectUint8StreamText } from "./collectUint8StreamText.ts"; +import { collectUint8StreamText } from "./collectStreamText.ts"; const encoder = new TextEncoder(); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/packages/provider-core/src/server/collectStreamText.ts similarity index 100% rename from apps/server/src/stream/collectUint8StreamText.ts rename to packages/provider-core/src/server/collectStreamText.ts diff --git a/apps/server/src/provider/ProviderDriver.ts b/packages/provider-core/src/server/driver.ts similarity index 95% rename from apps/server/src/provider/ProviderDriver.ts rename to packages/provider-core/src/server/driver.ts index d51061731b50..ed91c24d8833 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -19,7 +19,7 @@ * ChildProcessSpawner, …) on its `create` return type; the registry * layer's R is the union of those, and the runtime layer satisfies it. * - * @module provider/ProviderDriver + * @module provider-core/server/driver */ import type { ProviderConsumeResetCreditOutcome, @@ -37,11 +37,11 @@ import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; -import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; -import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; -import type { ProviderDriverError } from "./Errors.ts"; -import type { ProviderAuthController } from "./ProviderAuthService.ts"; -import type { ServerProviderShape } from "./ServerProvider.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAuthController } from "./auth.ts"; +import type { ProviderDriverError } from "./errors.ts"; +import type { ServerProviderShape } from "./snapshot.ts"; +import type { ProviderTextGeneration } from "./textGeneration.ts"; /** * Static metadata advertised by a driver. Used for default presentation @@ -96,7 +96,7 @@ export interface ProviderInstance { ProviderDriverError >; readonly orchestrationAdapter: ProviderAdapterV2Shape; - readonly textGeneration: TextGeneration["Service"]; + readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { readonly listSessions: (input: { diff --git a/packages/provider-core/src/server/errors.ts b/packages/provider-core/src/server/errors.ts new file mode 100644 index 000000000000..eda2eaa686ec --- /dev/null +++ b/packages/provider-core/src/server/errors.ts @@ -0,0 +1,21 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +/** + * ProviderDriverError - A driver `create` call failed before producing an + * instance. Surfaced to the registry, which marks the offending entry as + * an "unavailable" shadow snapshot rather than crashing the server. + */ +export class ProviderDriverError extends Schema.TaggedError()( + "ProviderDriverError", + { + driver: ProviderDriverKind, + instanceId: ProviderInstanceId, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; + } +} diff --git a/apps/server/src/orchestration-v2/ProviderFailure.test.ts b/packages/provider-core/src/server/failure.test.ts similarity index 97% rename from apps/server/src/orchestration-v2/ProviderFailure.test.ts rename to packages/provider-core/src/server/failure.test.ts index f05d3e91cd8a..47fe4c724eb1 100644 --- a/apps/server/src/orchestration-v2/ProviderFailure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -16,10 +16,10 @@ import { makeProviderFailureTurnItem, MAX_PROVIDER_FAILURE_CODE_LENGTH, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH, -} from "./ProviderFailure.ts"; + ContextHandoffBudgetError, +} from "./failure.ts"; import * as IdAllocator from "./IdAllocator.ts"; -import { ContextHandoffBudgetError } from "./ContextHandoffDelivery.ts"; -import { ProviderAdapterTurnStartError } from "./ProviderAdapter.ts"; +import { ProviderAdapterTurnStartError } from "@t3tools/provider-core/server/ProviderAdapter"; it("redacts credentials and URL secrets from provider failures", () => { const failure = makeProviderFailure({ diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/packages/provider-core/src/server/failure.ts similarity index 94% rename from apps/server/src/orchestration-v2/ProviderFailure.ts rename to packages/provider-core/src/server/failure.ts index 6cb9275017d9..53e663a4c8d9 100644 --- a/apps/server/src/orchestration-v2/ProviderFailure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -14,7 +14,17 @@ import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; import type { IdAllocatorV2Shape } from "./IdAllocator.ts"; -import { ContextHandoffBudgetError } from "./ContextHandoffDelivery.ts"; +import * as Schema from "effect/Schema"; + +/** Raised when portable history does not fit the target provider's context window. */ +export class ContextHandoffBudgetError extends Schema.TaggedError()( + "ContextHandoffBudgetError", + {}, +) { + override get message() { + return "Insufficient context allowance for the provider handoff. Compact the target conversation or use a larger-context model; the current request has not been truncated."; + } +} export const MAX_PROVIDER_FAILURE_MESSAGE_LENGTH = 4_096; export const MAX_PROVIDER_FAILURE_CODE_LENGTH = 128; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/packages/provider-core/src/server/instanceEnvironment.test.ts similarity index 96% rename from apps/server/src/provider/ProviderInstanceEnvironment.test.ts rename to packages/provider-core/src/server/instanceEnvironment.test.ts index 7d6bbe61a2aa..73d2ae3c313c 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/packages/provider-core/src/server/instanceEnvironment.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; -import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "./instanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { it.effect.each([ diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/packages/provider-core/src/server/instanceEnvironment.ts similarity index 92% rename from apps/server/src/provider/ProviderInstanceEnvironment.ts rename to packages/provider-core/src/server/instanceEnvironment.ts index 77c0c6c2dc88..ff8c9815731a 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/packages/provider-core/src/server/instanceEnvironment.ts @@ -1,6 +1,6 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; -import { expandHomePath } from "../pathExpansion.ts"; +import { expandHomePath } from "./pathExpansion.ts"; export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts new file mode 100644 index 000000000000..62f9a2ab63a6 --- /dev/null +++ b/packages/provider-core/src/server/maintenance.ts @@ -0,0 +1,30 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +export interface ProviderMaintenanceCapabilities { + readonly provider: ProviderDriverKind; + readonly packageName: string | null; + readonly update: ProviderMaintenanceCommandAction | null; + /** + * Latest version reported by the installer that owns the executable. + * `undefined` means the installer has no channel of its own and the npm + * registry entry for `packageName` is authoritative; `null` means the + * installer was asked and did not know. + */ + readonly latestVersion?: string | null; + /** Compare native release revisions when the provider does not use plain semver. */ + readonly compareVersions?: (current: string, latest: string) => number; +} + +export interface ProviderMaintenanceCommandAction { + readonly command: string; + readonly executable: string; + readonly args: ReadonlyArray; + readonly lockKey: string; + /** + * Extra environment for the spawned updater, on top of the server's own. + * A native updater finds its install through the same variables the + * provider runs with (e.g. `CODEX_HOME`), so an instance with a custom home + * must update that home and not the default one. + */ + readonly env?: NodeJS.ProcessEnv; +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts similarity index 99% rename from apps/server/src/provider/providerMaintenance.test.ts rename to packages/provider-core/src/server/maintenanceResolver.test.ts index b4bf670375ef..f93f35624c43 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -31,7 +31,7 @@ import { resolvePackageManagedProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, type ProviderMaintenanceCapabilities, -} from "./providerMaintenance.ts"; +} from "./maintenanceResolver.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const driver = (value: string) => ProviderDriverKind.make(value); diff --git a/apps/server/src/provider/providerMaintenance.ts b/packages/provider-core/src/server/maintenanceResolver.ts similarity index 96% rename from apps/server/src/provider/providerMaintenance.ts rename to packages/provider-core/src/server/maintenanceResolver.ts index 5dac5153a4f6..65667281ac6a 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -1,3 +1,11 @@ +export type { + ProviderMaintenanceCapabilities, + ProviderMaintenanceCommandAction, +} from "@t3tools/provider-core/server/maintenance"; +import type { + ProviderMaintenanceCapabilities, + ProviderMaintenanceCommandAction, +} from "@t3tools/provider-core/server/maintenance"; import { ProviderDriverKind, type ServerProvider, @@ -21,7 +29,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { collectUint8StreamText } from "./collectStreamText.ts"; const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; @@ -56,35 +64,6 @@ const CommandLookupEnvConfig = Config.all({ const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); -export interface ProviderMaintenanceCapabilities { - readonly provider: ProviderDriverKind; - readonly packageName: string | null; - readonly update: ProviderMaintenanceCommandAction | null; - /** - * Latest version reported by the installer that owns the executable. - * `undefined` means the installer has no channel of its own and the npm - * registry entry for `packageName` is authoritative; `null` means the - * installer was asked and did not know. - */ - readonly latestVersion?: string | null; - /** Compare native release revisions when the provider does not use plain semver. */ - readonly compareVersions?: (current: string, latest: string) => number; -} - -export interface ProviderMaintenanceCommandAction { - readonly command: string; - readonly executable: string; - readonly args: ReadonlyArray; - readonly lockKey: string; - /** - * Extra environment for the spawned updater, on top of the server's own. - * A native updater finds its install through the same variables the - * provider runs with (e.g. `CODEX_HOME`), so an instance with a custom home - * must update that home and not the default one. - */ - readonly env?: NodeJS.ProcessEnv; -} - /** Where the provider executable was found; every path is absolute. */ export interface ProviderMaintenanceResolutionContext { readonly binaryPath: string; diff --git a/apps/server/src/mcp/McpProviderSession.test.ts b/packages/provider-core/src/server/mcpSession.test.ts similarity index 94% rename from apps/server/src/mcp/McpProviderSession.test.ts rename to packages/provider-core/src/server/mcpSession.test.ts index 1c3c3af5091e..eb69826d3dc3 100644 --- a/apps/server/src/mcp/McpProviderSession.test.ts +++ b/packages/provider-core/src/server/mcpSession.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { withAgentDeviceEnvironment } from "./McpProviderSession.ts"; +import { withAgentDeviceEnvironment } from "./mcpSession.ts"; describe("device CLI environment", () => { it("preserves provider credentials and commands while routing devices to the owned daemon", () => { diff --git a/apps/server/src/mcp/McpProviderSession.ts b/packages/provider-core/src/server/mcpSession.ts similarity index 100% rename from apps/server/src/mcp/McpProviderSession.ts rename to packages/provider-core/src/server/mcpSession.ts diff --git a/apps/server/src/provider/McpToolPresentation.test.ts b/packages/provider-core/src/server/mcpToolPresentation.test.ts similarity index 98% rename from apps/server/src/provider/McpToolPresentation.test.ts rename to packages/provider-core/src/server/mcpToolPresentation.test.ts index 5218d05ebeb8..e53f0f37185f 100644 --- a/apps/server/src/provider/McpToolPresentation.test.ts +++ b/packages/provider-core/src/server/mcpToolPresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { mcpToolPresentation } from "./McpToolPresentation.ts"; +import { mcpToolPresentation } from "./mcpToolPresentation.ts"; describe("mcpToolPresentation", () => { it("uses supplied names and logos while retaining the server identity", () => { diff --git a/apps/server/src/provider/McpToolPresentation.ts b/packages/provider-core/src/server/mcpToolPresentation.ts similarity index 100% rename from apps/server/src/provider/McpToolPresentation.ts rename to packages/provider-core/src/server/mcpToolPresentation.ts diff --git a/apps/server/src/provider/T3OrchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts similarity index 98% rename from apps/server/src/provider/T3OrchestrationInstructions.test.ts rename to packages/provider-core/src/server/orchestrationInstructions.test.ts index e625e40c1125..4dd04c8ee8cd 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -5,7 +5,7 @@ import { t3AcpPromptWithInstructions, t3OrchestrationPromptForFirstRun, t3OrchestrationSystemPrompt, -} from "./T3OrchestrationInstructions.ts"; +} from "./orchestrationInstructions.ts"; describe("T3 orchestration provider instructions", () => { it("distinguishes delegated subagents from ordinary top-level threads", () => { diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts similarity index 100% rename from apps/server/src/provider/T3OrchestrationInstructions.ts rename to packages/provider-core/src/server/orchestrationInstructions.ts diff --git a/apps/server/src/pathExpansion.test.ts b/packages/provider-core/src/server/pathExpansion.test.ts similarity index 100% rename from apps/server/src/pathExpansion.test.ts rename to packages/provider-core/src/server/pathExpansion.test.ts diff --git a/apps/server/src/pathExpansion.ts b/packages/provider-core/src/server/pathExpansion.ts similarity index 100% rename from apps/server/src/pathExpansion.ts rename to packages/provider-core/src/server/pathExpansion.ts diff --git a/apps/server/src/process/processGroup.test.ts b/packages/provider-core/src/server/processGroup.test.ts similarity index 100% rename from apps/server/src/process/processGroup.test.ts rename to packages/provider-core/src/server/processGroup.test.ts diff --git a/apps/server/src/process/processGroup.ts b/packages/provider-core/src/server/processGroup.ts similarity index 100% rename from apps/server/src/process/processGroup.ts rename to packages/provider-core/src/server/processGroup.ts diff --git a/apps/server/src/orchestration-v2/RandomUuid.ts b/packages/provider-core/src/server/randomUuid.ts similarity index 100% rename from apps/server/src/orchestration-v2/RandomUuid.ts rename to packages/provider-core/src/server/randomUuid.ts diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.ts b/packages/provider-core/src/server/selectionTransition.ts similarity index 100% rename from apps/server/src/orchestration-v2/ProviderSelectionTransition.ts rename to packages/provider-core/src/server/selectionTransition.ts diff --git a/apps/server/src/provider/ServerProvider.ts b/packages/provider-core/src/server/snapshot.ts similarity index 92% rename from apps/server/src/provider/ServerProvider.ts rename to packages/provider-core/src/server/snapshot.ts index 77c16c14ee22..a6f2a985fd57 100644 --- a/apps/server/src/provider/ServerProvider.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,7 +1,7 @@ import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; -import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; export interface ServerProviderShape { /** diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/packages/provider-core/src/server/snapshotProbe.test.ts similarity index 99% rename from apps/server/src/provider/providerSnapshot.test.ts rename to packages/provider-core/src/server/snapshotProbe.test.ts index 32d8649d2699..d15e8744cc0c 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/packages/provider-core/src/server/snapshotProbe.test.ts @@ -14,7 +14,7 @@ import { parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, -} from "./providerSnapshot.ts"; +} from "./snapshotProbe.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ diff --git a/apps/server/src/provider/providerSnapshot.ts b/packages/provider-core/src/server/snapshotProbe.ts similarity index 89% rename from apps/server/src/provider/providerSnapshot.ts rename to packages/provider-core/src/server/snapshotProbe.ts index f180c5facb1c..b29abeed7cc2 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -17,9 +17,30 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { readCustomModelEntries } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; -import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; -import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { createProviderVersionAdvisory } from "./maintenanceResolver.ts"; +import { collectUint8StreamText } from "./collectStreamText.ts"; + +// Localized cmd.exe "not recognized" messages for a missing executable. +const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ + /is not recognized as an internal or external command/i, + /n.o . reconhecido como um comando interno/i, + /non . riconosciuto come comando interno o esterno/i, + /n.est pas reconnu en tant que commande interne/i, + /no se reconoce como un comando interno o externo/i, + /wird nicht als interner oder externer befehl/i, +] as const; + +/** Whether a Windows exit means the command was not found rather than that it failed. */ +export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(function* ( + code: number | null, + stderr: string, +) { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") return false; + if (code === 9009) return true; + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(stderr)); +}); export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts new file mode 100644 index 000000000000..95039c2d17ff --- /dev/null +++ b/packages/provider-core/src/server/textGeneration.ts @@ -0,0 +1,104 @@ +import type { + BranchNamingOptions, + ChatAttachment, + ModelSelection, + TextGenerationError, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; + +import type { TextGenerationPolicy } from "./textGenerationPolicy.ts"; + +export interface CommitMessageGenerationInput { + cwd: string; + branch: string | null; + stagedSummary: string; + stagedPatch: string; + /** When true, the model also returns a semantic branch name for the change. */ + includeBranch?: boolean; + policy?: TextGenerationPolicy | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface CommitMessageGenerationResult { + subject: string; + body: string; + /** Only present when `includeBranch` was set on the input. */ + branch?: string | undefined; +} + +export interface PrContentGenerationInput { + cwd: string; + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; + changeRequestTemplate?: string | undefined; + policy?: TextGenerationPolicy | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface PrContentGenerationResult { + title: string; + body: string; +} + +export interface BranchNameGenerationInput { + naming?: BranchNamingOptions | undefined; + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface BranchNameGenerationResult { + branch: string; +} + +export interface ThreadTitleGenerationInput { + linkedContext?: string | undefined; + cwd: string; + message: string; + /** Present when replacing an existing title from the current thread history. */ + previousTitle?: string | undefined; + attachments?: ReadonlyArray | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadTitleGenerationResult { + title: string; + needsRefinement?: boolean | undefined; +} + +/** Commit, change request, branch, and title generation backed by one provider instance. */ +export interface ProviderTextGeneration { + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate change request title/body from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; + + /** Generate a concise thread title from a first message or thread history. */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; +} diff --git a/packages/provider-core/src/server/textGenerationOperations.ts b/packages/provider-core/src/server/textGenerationOperations.ts new file mode 100644 index 000000000000..723095f3e95a --- /dev/null +++ b/packages/provider-core/src/server/textGenerationOperations.ts @@ -0,0 +1,157 @@ +/** + * The text generation operations every provider shares. A provider supplies a + * runner that sends one prompt and decodes the reply; prompts, linked source + * control context, and output sanitizing live here so providers cannot drift + * on them. + * + * @module textGeneration/TextGenerationOperations + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { type ChatAttachment, type ModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import type { ProviderTextGeneration } from "./textGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./textGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./textGenerationUtils.ts"; + +export type Operation = keyof ProviderTextGeneration; + +/** One prompt for a provider to run. */ +export interface Request { + readonly operation: Operation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchema: S; + readonly modelSelection: ModelSelection; + /** The user's attachments, already listed in the prompt. Providers that accept images send them. */ + readonly attachments?: ReadonlyArray | undefined; +} + +/** Runs one request and decodes the reply as its `outputSchema`. */ +export type Runner = ( + request: Request, +) => Effect.Effect; + +/** Decodes the JSON object in a text reply, which models often wrap in prose. */ +export const decodeJsonReply = ( + request: Pick, "operation" | "outputSchema">, + provider: string, + text: string, +) => { + // Each request builds its own output schema, so the decoder cannot be hoisted. + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(request.outputSchema)); + return decodeOutput(extractJsonObject(text)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: request.operation, + detail: `${provider} returned invalid structured output.`, + cause, + }), + ), + ); +}; + +/** The text generation service over `run`. `name` prefixes each operation's span. */ +export function fromRunner(name: string, run: Runner): ProviderTextGeneration { + const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( + `${name}.generateCommitMessage`, + )(function* (input) { + const generated = yield* run({ + operation: "generateCommitMessage", + cwd: input.cwd, + modelSelection: input.modelSelection, + ...buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }), + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn( + `${name}.generatePrContent`, + )(function* (input) { + const generated = yield* run({ + operation: "generatePrContent", + cwd: input.cwd, + modelSelection: input.modelSelection, + ...buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }), + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + + const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn( + `${name}.generateBranchName`, + )(function* (input) { + const generated = yield* run({ + operation: "generateBranchName", + cwd: input.cwd, + modelSelection: input.modelSelection, + attachments: input.attachments, + ...buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + naming: input.naming, + }), + }); + return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; + }); + + const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn( + `${name}.generateThreadTitle`, + )(function* (input) { + const generated = yield* run({ + operation: "generateThreadTitle", + cwd: input.cwd, + modelSelection: input.modelSelection, + attachments: input.attachments, + ...buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + linkedContext: input.linkedContext, + attachments: input.attachments, + }), + }); + return { + title: sanitizeThreadTitle(generated.title), + ...(generated.needsRefinement ? { needsRefinement: true } : {}), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies ProviderTextGeneration; +} diff --git a/apps/server/src/textGeneration/TextGenerationPolicy.ts b/packages/provider-core/src/server/textGenerationPolicy.ts similarity index 100% rename from apps/server/src/textGeneration/TextGenerationPolicy.ts rename to packages/provider-core/src/server/textGenerationPolicy.ts diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/packages/provider-core/src/server/textGenerationPrompts.test.ts similarity index 99% rename from apps/server/src/textGeneration/TextGenerationPrompts.test.ts rename to packages/provider-core/src/server/textGenerationPrompts.test.ts index fada659aded1..d4446502a915 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.test.ts @@ -5,12 +5,12 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; +} from "./textGenerationPrompts.ts"; import { normalizeCliError, sanitizeThreadTitle, toJsonSchemaObject, -} from "./TextGenerationUtils.ts"; +} from "./textGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/packages/provider-core/src/server/textGenerationPrompts.ts similarity index 98% rename from apps/server/src/textGeneration/TextGenerationPrompts.ts rename to packages/provider-core/src/server/textGenerationPrompts.ts index 236636121095..aaf19aeb6ee2 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.ts @@ -8,11 +8,11 @@ */ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; -import { limitTitleMessage } from "./ThreadTitleContext.ts"; +import { limitTitleMessage } from "./threadTitleContext.ts"; import type { BranchNamingOptions, ChatAttachment } from "@t3tools/contracts"; -import { limitSection } from "./TextGenerationUtils.ts"; -import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; +import { limitSection } from "./textGenerationUtils.ts"; +import type { TextGenerationPolicy } from "@t3tools/provider-core/server/textGenerationPolicy"; const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/packages/provider-core/src/server/textGenerationUtils.ts similarity index 100% rename from apps/server/src/textGeneration/TextGenerationUtils.ts rename to packages/provider-core/src/server/textGenerationUtils.ts diff --git a/apps/server/src/textGeneration/ThreadTitleContext.test.ts b/packages/provider-core/src/server/threadTitleContext.test.ts similarity index 97% rename from apps/server/src/textGeneration/ThreadTitleContext.test.ts rename to packages/provider-core/src/server/threadTitleContext.test.ts index 14b9de23ab90..99debc552eab 100644 --- a/apps/server/src/textGeneration/ThreadTitleContext.test.ts +++ b/packages/provider-core/src/server/threadTitleContext.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatThreadTitleContext, limitTitleMessage } from "./ThreadTitleContext.ts"; +import { formatThreadTitleContext, limitTitleMessage } from "./threadTitleContext.ts"; describe("thread title context", () => { it("keeps a user's scope change despite long assistant output", () => { diff --git a/apps/server/src/textGeneration/ThreadTitleContext.ts b/packages/provider-core/src/server/threadTitleContext.ts similarity index 100% rename from apps/server/src/textGeneration/ThreadTitleContext.ts rename to packages/provider-core/src/server/threadTitleContext.ts diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts similarity index 98% rename from apps/server/src/provider/providerUsageLimits.test.ts rename to packages/provider-core/src/server/usageLimits.test.ts index 6e288ddd3a36..9b305a74b98e 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; const checkedAt = "2026-09-03T12:00:00.000Z"; const session = { diff --git a/apps/server/src/provider/providerUsageLimits.ts b/packages/provider-core/src/server/usageLimits.ts similarity index 100% rename from apps/server/src/provider/providerUsageLimits.ts rename to packages/provider-core/src/server/usageLimits.ts diff --git a/packages/provider-core/tsconfig.json b/packages/provider-core/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-testing/package.json b/packages/provider-testing/package.json new file mode 100644 index 000000000000..35e3941ba87d --- /dev/null +++ b/packages/provider-testing/package.json @@ -0,0 +1,38 @@ +{ + "name": "@t3tools/provider-testing", + "private": true, + "type": "module", + "exports": { + "./replayTranscript": { + "types": "./src/replayTranscript.ts", + "import": "./src/replayTranscript.ts" + }, + "./replayGate": { + "types": "./src/replayGate.ts", + "import": "./src/replayGate.ts" + }, + "./replayWorkspace": { + "types": "./src/replayWorkspace.ts", + "import": "./src/replayWorkspace.ts" + }, + "./host": { + "types": "./src/host.ts", + "import": "./src/host.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts new file mode 100644 index 000000000000..1b3e74922eb5 --- /dev/null +++ b/packages/provider-testing/src/host.ts @@ -0,0 +1,54 @@ +/** + * A `ProviderHost` for driver and adapter tests. Its directories live in a + * scoped temp directory, settings are fixed, and background work always runs + * unless the test says otherwise. + * + * @module provider-testing/host + */ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +export interface TestProviderHostOptions { + /** Session fallback cwd. Defaults to the test process cwd. */ + readonly cwd?: string; + readonly settings?: ServerSettings; + /** Whether background work such as status probes may run. Defaults to `true`. */ + readonly runBackgroundWork?: boolean; +} + +export const layerTestProviderHost = ( + options: TestProviderHostOptions = {}, +): Layer.Layer => + Layer.effect( + ProviderHost, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-provider-host-" }) + .pipe(Effect.orDie); + const stateDir = path.join(baseDir, "userdata"); + const providerStatusCacheDir = path.join(baseDir, "caches"); + const attachmentsDir = path.join(stateDir, "attachments"); + for (const directory of [stateDir, providerStatusCacheDir, attachmentsDir]) { + yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); + } + const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; + return ProviderHost.of({ + paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, + settings: { + get: Effect.succeed(settings), + changes: Stream.empty, + subscribe: Effect.succeed(Stream.empty), + }, + shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), + // Tests store attachments flat under the attachments directory by id. + resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), + }); + }), + ); diff --git a/packages/provider-testing/src/replayGate.test.ts b/packages/provider-testing/src/replayGate.test.ts new file mode 100644 index 000000000000..72382ab823d4 --- /dev/null +++ b/packages/provider-testing/src/replayGate.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { makeProviderReplayGate } from "./replayGate.ts"; + +describe("makeProviderReplayGate", () => { + it("holds a gated emit until its label is released", async () => { + const gate = makeProviderReplayGate(["approval"]); + let emitted = false; + const emit = gate.beforeEmit("approval").then(() => { + emitted = true; + }); + + expect(await gate.waitForReached("approval")).toBe(true); + expect(emitted).toBe(false); + expect(gate.release("approval")).toBe(true); + await emit; + expect(emitted).toBe(true); + expect(gate.release("approval")).toBe(false); + }); + + it("passes ungated labels through and stops waiting when the emit is aborted", async () => { + const gate = makeProviderReplayGate(["approval"]); + await gate.beforeEmit(undefined); + await gate.beforeEmit("other"); + + const controller = new AbortController(); + const emit = gate.beforeEmit("approval", controller.signal); + controller.abort(); + await emit; + expect(gate.hasReached("approval")).toBe(true); + }); + + it("rejects duplicate labels", () => { + expect(() => makeProviderReplayGate(["approval", "approval"])).toThrow( + "Duplicate provider replay gate label approval.", + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayGate.testkit.ts b/packages/provider-testing/src/replayGate.ts similarity index 100% rename from apps/server/src/orchestration-v2/testkit/ProviderReplayGate.testkit.ts rename to packages/provider-testing/src/replayGate.ts diff --git a/packages/provider-testing/src/replayTranscript.test.ts b/packages/provider-testing/src/replayTranscript.test.ts new file mode 100644 index 000000000000..fc5fe57adaf2 --- /dev/null +++ b/packages/provider-testing/src/replayTranscript.test.ts @@ -0,0 +1,96 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + decodeProviderReplayNdjson, + materializeReplayTranscriptWorkspace, +} from "./replayTranscript.ts"; + +const header = JSON.stringify({ + type: "transcript_start", + provider: "codex", + protocol: "codex.app-server", + version: "0.156.1", + scenario: "simple", +}); + +describe("decodeProviderReplayNdjson", () => { + it.effect("reads the header and entries, skipping blank lines", () => + Effect.gen(function* () { + const transcript = yield* decodeProviderReplayNdjson( + [ + header, + "", + JSON.stringify({ type: "expect_outbound", frame: { method: "initialize" } }), + JSON.stringify({ type: "emit_inbound", frame: { result: {} } }), + ].join("\n"), + ); + + assert.strictEqual(transcript.provider, "codex"); + assert.strictEqual(transcript.scenario, "simple"); + assert.deepStrictEqual( + transcript.entries.map((entry) => entry.type), + ["expect_outbound", "emit_inbound"], + ); + }), + ); + + it.effect("rejects a second transcript_start record", () => + Effect.gen(function* () { + const error = yield* Effect.flip(decodeProviderReplayNdjson([header, header].join("\n"))); + + assert.strictEqual(error._tag, "ProviderReplayNdjsonLineParseError"); + assert.strictEqual( + error._tag === "ProviderReplayNdjsonLineParseError" && error.lineNumber, + 2, + ); + }), + ); + + it.effect("requires a header unless fallback metadata is given", () => + Effect.gen(function* () { + const entry = JSON.stringify({ type: "emit_inbound", frame: {} }); + + const error = yield* Effect.flip(decodeProviderReplayNdjson(entry)); + assert.strictEqual(error._tag, "ProviderReplayNdjsonMissingHeaderError"); + + const transcript = yield* decodeProviderReplayNdjson(entry, { + provider: "pi", + protocol: "pi.rpc-jsonl", + version: "1", + scenario: "fallback", + }); + assert.strictEqual(transcript.entries.length, 1); + }), + ); +}); + +describe("materializeReplayTranscriptWorkspace", () => { + it("replaces workspace placeholders in outbound frames only", () => { + const transcript = materializeReplayTranscriptWorkspace( + { + provider: "codex", + protocol: "codex.app-server", + version: "0.156.1", + scenario: "simple", + entries: [ + { + type: "expect_outbound", + frame: { params: { cwd: "", argv: [""] } }, + }, + { type: "emit_inbound", frame: { cwd: "" } }, + ], + }, + "/tmp/fixture", + ); + + assert.deepStrictEqual(transcript.entries[0], { + type: "expect_outbound", + frame: { params: { cwd: "/tmp/fixture", argv: ["/tmp/fixture"] } }, + }); + assert.deepStrictEqual(transcript.entries[1], { + type: "emit_inbound", + frame: { cwd: "" }, + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts b/packages/provider-testing/src/replayTranscript.ts similarity index 70% rename from apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts rename to packages/provider-testing/src/replayTranscript.ts index 1b62ca0f126e..67bd754f5d2d 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayTranscriptNdjson.ts +++ b/packages/provider-testing/src/replayTranscript.ts @@ -2,7 +2,6 @@ import { ProviderReplayEntry, ProviderReplayNdjsonRecord, ProviderReplayTranscript, - type ProviderDriverKind, type ProviderReplayTranscriptHeader, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -10,8 +9,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; - export class ProviderReplayNdjsonLineParseError extends Schema.TaggedError()( "ProviderReplayNdjsonLineParseError", { @@ -93,73 +90,6 @@ export function materializeReplayTranscriptWorkspace( }; } -/** Adds current runtime context to legacy prompt expectations, keeping outbound matching exact. */ -export function materializeReplayTranscriptRuntimeInstructions( - transcript: ProviderReplayTranscript, - runtime: { readonly driver: ProviderDriverKind; readonly model: string }, -): ProviderReplayTranscript { - const harness = - runtime.driver === "cursor" - ? "Cursor" - : runtime.driver === "grok" - ? "Grok" - : runtime.driver === "acpRegistry" - ? "acpRegistry" - : undefined; - if (harness === undefined) return transcript; - const instructions = buildRuntimeInstructions({ harness, model: runtime.model }); - - return { - ...transcript, - entries: transcript.entries.map((entry) => { - if (entry.type !== "expect_outbound") return entry; - const frame = entry.frame; - if (typeof frame !== "object" || frame === null) return entry; - if ( - runtime.driver === "cursor" && - "type" in frame && - frame.type === "run.start" && - "message" in frame && - typeof frame.message === "string" - ) { - return frame.message.endsWith(instructions) - ? entry - : { ...entry, frame: { ...frame, message: `${frame.message}\n\n${instructions}` } }; - } - if ( - "method" in frame && - frame.method === "session/prompt" && - "params" in frame && - typeof frame.params === "object" && - frame.params !== null && - "prompt" in frame.params && - Array.isArray(frame.params.prompt) - ) { - const prompt: ReadonlyArray = frame.params.prompt; - const lastPart = prompt.at(-1); - if ( - typeof lastPart === "object" && - lastPart !== null && - "type" in lastPart && - lastPart.type === "text" && - "text" in lastPart && - lastPart.text === instructions - ) { - return entry; - } - return { - ...entry, - frame: { - ...frame, - params: { ...frame.params, prompt: [...prompt, { type: "text", text: instructions }] }, - }, - }; - } - return entry; - }), - }; -} - const decodeReplayRecord = Schema.decodeUnknownSync( Schema.fromJsonString(ProviderReplayNdjsonRecord), ); diff --git a/apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts b/packages/provider-testing/src/replayWorkspace.ts similarity index 100% rename from apps/server/src/orchestration-v2/testkit/ReplayFixtureWorkspace.ts rename to packages/provider-testing/src/replayWorkspace.ts diff --git a/packages/provider-testing/tsconfig.json b/packages/provider-testing/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-testing/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed53ec3b655..a3da3784edd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: '@opencode/schema': specifier: 2.0.23 version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) + '@t3tools/provider-core': + specifier: workspace:* + version: link:../../packages/provider-core diff: specifier: 8.0.3 version: 8.0.3 @@ -634,6 +637,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../../packages/provider-testing '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -715,6 +721,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../../packages/provider-core '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -1053,6 +1062,56 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + packages/provider-testing: + dependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/shared: dependencies: '@noble/curves': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 963232dbbbd2..296b3ac1441f 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -28,6 +28,8 @@ const workspaceFiles = [ "packages/shared/package.json", "packages/ssh/package.json", "packages/tailscale/package.json", + "packages/provider-core/package.json", + "packages/provider-testing/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", From 2c1160783ea4b4dcc1c1f1ab04639ffc0aaa289d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 14:18:17 -0700 Subject: [PATCH 024/377] refactor(settings): drop the legacy per-driver providers map (#17300) Co-authored-by: Claude Opus 5.5 --- .../CursorOrchestratorV2.live.test.ts | 9 +- .../GrokOrchestratorV2.live.test.ts | 9 +- .../src/project/AgentSessionScanner.test.ts | 20 +- .../server/src/project/AgentSessionScanner.ts | 13 +- .../src/provider/Drivers/AcpRegistryDriver.ts | 1 + .../ProviderInstanceRegistryHydration.ts | 64 +-- .../src/provider/ProviderRegistry.test.ts | 68 ++- .../src/provider/providerInstallation.test.ts | 27 +- apps/server/src/server.ts | 3 +- apps/server/src/serverSettings.test.ts | 413 +++++------------- apps/server/src/serverSettings.ts | 169 ++++--- apps/server/src/terminal/Manager.test.ts | 44 +- apps/server/src/usage/UsageService.test.ts | 189 ++++---- apps/server/src/usage/UsageService.ts | 9 +- .../components/onboarding/WelcomeWizard.tsx | 5 +- .../settings/AddProviderInstanceDialog.tsx | 17 +- ...ProviderSettingsPanel.environment.test.tsx | 16 +- .../settings/ProviderSettingsPanel.tsx | 86 +--- .../settings/SettingsPanels.logic.test.ts | 46 +- .../settings/SettingsPanels.logic.ts | 19 +- apps/web/src/modelSelection.test.ts | 11 +- apps/web/src/modelSelection.ts | 17 +- .../providerReadiness.logic.test.ts | 66 +-- .../src/onboarding/providerReadiness.logic.ts | 8 +- apps/web/src/providerInstances.test.ts | 26 +- apps/web/src/providerInstances.ts | 28 +- docs/internals/adding-a-provider.md | 5 +- packages/contracts/src/settings.test.ts | 107 +---- packages/contracts/src/settings.ts | 136 ++---- packages/provider-core/src/server/driver.ts | 13 +- packages/shared/src/projectSettings.test.ts | 15 +- packages/shared/src/serverSettings.ts | 16 +- 32 files changed, 602 insertions(+), 1073 deletions(-) diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 3713c7e6766a..d6dc569db2b6 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -7,6 +7,8 @@ import { CommandId, MessageId, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; @@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); const layerServerSettings = ServerSettings.layerTest({ - providers: { - cursor: { enabled: true }, + providerInstances: { + [ProviderInstanceId.make("cursor")]: { + driver: ProviderDriverKind.make("cursor"), + enabled: true, + }, }, }); const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 3cec0048ca34..b6b5eee97a0d 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -8,6 +8,8 @@ import { MessageId, type OrchestrationV2ThreadProjection, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import * as Console from "effect/Console"; @@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); const layerServerSettings = ServerSettings.layerTest({ - providers: { - grok: { enabled: true }, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + enabled: true, + }, }, }); const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index ab77e5e491a0..6be4c661e72a 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) => Layer.provide( Layer.mergeAll( ServerSettings.layerTest({ - providers: { - claudeAgent: { homePath: input.claudeHomePath }, - codex: { homePath: input.codexHomePath }, + // Default slots point at the fixture homes; explicit test instances + // replace a slot with the same id. + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: input.claudeHomePath }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: input.codexHomePath }, + }, + ...input.providerInstances, }, - ...(input.providerInstances === undefined - ? {} - : { providerInstances: input.providerInstances }), }), ServerConfig.layerTest( input.claudeHomePath, @@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + it.effect("uses explicit default-slot homes instead of the fixture homes", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d67b5d72639c..0fe74152f1c3 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1102,16 +1102,15 @@ export const make = Effect.gen(function* () { instanceId: ProviderInstanceId.make(instanceId), config, })); + // The built-in default instance runs with default config when settings + // have no entry for it. if (!Object.hasOwn(settings.providerInstances, source)) { - const legacyInstance = { + const defaultInstance = { instanceId: ProviderInstanceId.make(source), - config: { - driver: ProviderDriverKind.make(source), - config: settings.providers[source], - }, + config: { driver: ProviderDriverKind.make(source) }, }; - if (resolveProviderInstanceEnabled(legacyInstance.config)) { - instances.push(legacyInstance); + if (resolveProviderInstanceEnabled(defaultInstance.config)) { + instances.push(defaultInstance); } } diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index da992744b36f..cc08d2b4a10e 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -467,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver decodeSettings({}), diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 50627de3d337..177aace04c57 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -2,28 +2,10 @@ * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. * - * The server still reads two shapes: - * - * 1. `settings.providerInstances` — the new driver-agnostic map the - * registry expects. Keyed by `ProviderInstanceId`, values are - * `ProviderInstanceConfig` envelopes. - * 2. `settings.providers.` — the legacy single-instance-per-driver - * fields (`providers.codex`, `providers.claudeAgent`, …). These are - * the source of truth for every deployment that hasn't been migrated - * yet to an explicit `providerInstances` entry. - * - * This module bridges (2) into (1) and wires the resulting map into a - * mutable registry. For every built-in driver whose id is not already - * present in `providerInstances` (keyed on - * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a - * routing slug), we synthesize an envelope from the legacy field. The - * registry decodes both flavours through the same `configSchema` and ends - * up with one uniform `ProviderInstance` per entry. - * - * Explicit `providerInstances` entries always win — users can already - * override the legacy `providers.` blob by authoring a - * `providerInstances.codex` entry with a matching driver, and we don't - * want the synthesized envelope to silently stomp their config. + * `settings.providerInstances` is the source of truth. Every built-in driver + * with a default instance also runs at `defaultInstanceIdForDriver(kind)` + * when that slot has no entry, using the driver's default config, so a fresh + * install shows its built-in providers without writing settings first. * * Hot-reload * ---------- @@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv = | ServerConfig.ServerConfig; /** - * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. - * - * Strategy: - * 1. Copy all explicit `settings.providerInstances` entries verbatim. - * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key - * is *not* already in the explicit map, synthesize an entry from the - * matching legacy `settings.providers.` blob. - * - * The returned map is the input the registry consumes; pure & exported - * separately so the hydration logic can be exercised by unit tests - * without layering. + * Explicit `providerInstances` entries plus an implicit default instance for + * each built-in driver whose default slot is empty. Pure so the hydration + * rule can be tested without layers. */ export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, @@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = ( const merged: Record = { ...settings.providerInstances }; for (const driver of BUILT_IN_DRIVERS) { + if (driver.metadata.hasDefaultInstance === false) continue; const instanceId = defaultInstanceIdForDriver(driver.driverKind); - if (instanceId in merged) { - // Explicit `providerInstances` entry for this slot — user-authored - // config always wins over the legacy mirror. - continue; - } - - // Only built-in drivers have a legacy mirror; the registry's - // `providers` struct is keyed on the same literal slug as - // `driverKind`. Access is dynamic (the driver kind is a branded string), - // but it's constrained to `keyof settings.providers` by the union of - // built-in driver kinds. - const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; - const legacyConfig = settings.providers[legacyKey]; - if (legacyConfig === undefined) { - continue; - } - - merged[instanceId] = { - driver: driver.driverKind, - config: legacyConfig, - }; + if (instanceId in merged) continue; + merged[instanceId] = { driver: driver.driverKind }; } return merged as ProviderInstanceConfigMap; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d5fc2b2a5809..11cab0e1d011 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); +/** Default slots that settings mark disabled, so their probes never spawn. */ +const disabledDefaultSlots = ( + ...drivers: ReadonlyArray +): ContractServerSettings["providerInstances"] => + Object.fromEntries( + drivers.map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: false }, + ]), + ); + const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -2663,24 +2674,18 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - // Disable every built-in probe that would otherwise spawn - // on the CI host. `enabled: false` short-circuits each - // driver's probe *before* it touches the spawner, so the - // test environment stays isolated from the dev - // machine's PATH. - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals // at the TS level even though the runtime schema happily // accepts + decodes them. Cast the patch to `unknown` so // the `Schema.decodeSync` below does the real validation. providerInstances: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), // Matches the shape the user had in `.t3/dev/settings.json` // when the bug was reported: a custom enabled Codex instance // pointing at a binary the server has to actually spawn. @@ -2781,12 +2786,13 @@ it.layer( const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: true, binaryPath: firstMissing }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, + providerInstances: { + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: firstMissing }, + }, }, }), ), @@ -2871,8 +2877,13 @@ it.layer( ), ); yield* serverSettings.updateSettings({ - providers: { - codex: { enabled: true, binaryPath: secondMissing }, + providerInstances: { + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: secondMissing }, + }, }, }); // Start the lazy stream only after publishing. A watcher that did @@ -2900,14 +2911,8 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, providerInstances: { + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), ghost_main: { driver: "ghostDriver", displayName: "A fork-only driver we don't ship", @@ -2971,14 +2976,7 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { - enabled: false, - }, - grok: { - enabled: false, - }, - }, + providerInstances: disabledDefaultSlots("codex", "grok"), }), ), ); diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index d180b0e2dd99..ae2064fc85fa 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -147,7 +147,14 @@ describe("provider installation routing", () => { it.effect("keeps external installs manual without hiding shared install status", () => Effect.gen(function* () { const harness = yield* makeHarness({ - settings: { providers: { antigravity: { binaryPath: "/external/agy" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("antigravity")]: { + driver: ProviderDriverKind.make("antigravity"), + config: { binaryPath: "/external/agy" }, + }, + }, + }, }); const start = yield* Effect.flip(harness.router.start({ instanceId })); const remove = yield* Effect.flip(harness.router.remove({ instanceId })); @@ -164,7 +171,14 @@ describe("provider installation routing", () => { const codexId = ProviderInstanceId.make("codex"); const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { providers: { codex: { setupMode: "managed" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed" }, + }, + }, + }, }); assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); @@ -180,7 +194,14 @@ describe("provider installation routing", () => { const codexId = ProviderInstanceId.make("codex"); const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { providers: { codex: { setupMode: "existing" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "existing" }, + }, + }, + }, }); assert.include( (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8c50ab6258ff..b6c0d99848ae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -583,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; - // `providerInstances` hydration merges `settings.providers.` - // with explicit `providerInstances` entries on boot. + // hydration adds their default instances to `providerInstances` on boot. Layer.provideMerge(ProviderInstanceRegistryHydration.layer), Layer.provideMerge( Layer.mergeAll( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 051f05331613..1611b7dfddb9 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("decodes nested settings patches", () => Effect.gen(function* () { - assert.deepEqual( - yield* decodeSettingsPatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), - { - providers: { codex: { binaryPath: "/tmp/codex" } }, - }, - ); - assert.deepEqual( yield* decodeSettingsPatch({ textGenerationModelSelection: { @@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/usr/local/bin/codex", - homePath: "/Users/julius/.codex", - }, - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - customModels: ["claude-custom"], - }, - }, + observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, textGenerationModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, @@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - }, + observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" }, textGenerationModelSelection: { options: [{ id: "fastMode", value: false }], }, }); - assert.deepEqual(next.providers.codex, { - enabled: true, - binaryPath: "/opt/homebrew/bin/codex", - homePath: "/Users/julius/.codex", - shadowHomePath: "", - launchArgs: "", - customModels: [], - }); - assert.deepEqual(next.providers.claudeAgent, { - enabled: true, - binaryPath: "/usr/local/bin/claude", - homePath: "", - customModels: ["claude-custom"], - launchArgs: "", - autoCompactWindow: "", - }); + assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); + assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); assert.deepEqual( next.textGenerationModelSelection, createModelSelection( @@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { const changes = yield* serverSettings.subscribeChanges; yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/usr/local/bin/codex-next", - }, - }, + addProjectBaseDirectory: "~/next", }); const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); - assert.equal( - Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, - "/usr/local/bin/codex-next", - ); + assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next"); }), ).pipe(Effect.provide(layerServerSettings())), ); @@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); it.effect( - "uses explicit provider instance enabled state over legacy provider enabled state", + "keeps a custom instance's selection when the driver's default instance is disabled", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("claude_openrouter"); const next = yield* serverSettings.updateSettings({ - providers: { - claudeAgent: { + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), enabled: false, }, - }, - providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, @@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fallbackInstanceId = ProviderInstanceId.make(fallbackId); const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; const providerInstances = { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, [fallbackInstanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, @@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }; yield* serverSettings.updateSettings({ - providers: Object.fromEntries( - Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( - (provider) => [provider, { enabled: false }] as const, - ), - ), providerInstances, textGenerationModelSelection: selection, }); @@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => + it.effect("skips explicitly disabled default instances when choosing a fallback", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ @@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("enables previously used providers from sparse settings files", () => + it.effect("moves customized legacy provider settings into default instances on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', + JSON.stringify({ + addProjectBaseDirectory: "~/Development", + providers: { + codex: { binaryPath: "/opt/guard/bin/codex" }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + }), ); - yield* recordProviderUsage("opencode"); const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isTrue(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); + assert.deepEqual(settings.providerInstances, { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { binaryPath: "/opt/guard/bin/codex" }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }); + // The file is rewritten once: instances persist and the retired map is gone. + const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.isUndefined(persisted.providers); + assert.equal(persisted.addProjectBaseDirectory, "~/Development"); + assert.deepEqual(persisted.providerInstances.codex, { + driver: "codex", + config: { binaryPath: "/opt/guard/bin/codex" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves existing provider instances without explicit enabled flags", () => + it.effect("keeps an explicit default instance over the legacy blob for its driver", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', + JSON.stringify({ + providers: { codex: { binaryPath: "/legacy/codex" } }, + providerInstances: { + codex: { driver: "codex", enabled: true, config: { binaryPath: "/explicit/codex" } }, + }, + }), ); - yield* recordProviderUsage("cursor", "cursor_work"); - yield* recordProviderUsage("grok", null); - yield* recordProviderUsage("opencode", "opencode_work"); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.cursor.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); - const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; - assert.isDefined(unused); - assert.isFalse(resolveProviderInstanceEnabled(unused)); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: "/explicit/codex" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves explicit provider disables in existing settings files", () => + it.effect("enables previously used optional providers while migrating legacy settings", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', + JSON.stringify({ + providers: { opencode: { serverUrl: "http://127.0.0.1:4096" }, cursor: {} }, + providerInstances: { + cursor_work: { driver: "cursor", config: {} }, + opencode_unused: { driver: "opencode", config: {} }, + }, + }), ); - yield* recordProviderUsage("grok"); yield* recordProviderUsage("opencode"); - yield* recordProviderUsage("cursor"); + yield* recordProviderUsage("grok", null); + yield* recordProviderUsage("cursor", "cursor_work"); const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], { + driver: ProviderDriverKind.make("opencode"), + enabled: true, + config: { serverUrl: "http://127.0.0.1:4096" }, + }); + // Used without any legacy blob or instance: the slot is created enabled. + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + // Using any cursor instance counts as opting into the driver. + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; + assert.isDefined(unused); + assert.isFalse(resolveProviderInstanceEnabled(unused)); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips a disabled provider instance when picking the text generation fallback", () => + it.effect("keeps explicit legacy disables even when provider history shows use", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - // The Providers UI writes providerInstances only, so the legacy providers - // map decodes to defaults where codex is enabled and listed first. yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', + JSON.stringify({ providers: { grok: { enabled: false, binaryPath: "/opt/grok" } } }), ); + yield* recordProviderUsage("grok"); const settings = yield* serverSettings.getSettings; - assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("grok")], { + driver: ProviderDriverKind.make("grok"), + enabled: false, + config: { binaryPath: "/opt/grok" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("keeps unused providers disabled in existing sparse settings files", () => + it.effect("runs provider history only for files that still carry the legacy map", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); - - const settings = yield* serverSettings.getSettings; - - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("preserves provider history when no settings file exists", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* recordProviderUsage("grok"); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); + assert.deepEqual(settings.providerInstances, {}); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves provider history when the settings file is invalid", () => + it.effect("migrates legacy providers from an invalid file without rewriting it", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); - yield* recordProviderUsage("cursor"); + const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, raw); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.cursor.enabled); - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); + // The readable provider settings still apply, but the file stays for the user to repair. + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { + driver: ProviderDriverKind.make("codex"), + config: { binaryPath: "/x" }, + }); + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves valid provider flags when another settings field is invalid", () => + it.effect("skips a disabled provider instance when picking the text generation fallback", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', ); - yield* recordProviderUsage("cursor"); - - const settings = yield* serverSettings.getSettings; - - assert.isFalse(settings.providers.cursor.enabled); - assert.isTrue(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("restores providers from persisted runtime sessions", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const sql = yield* SqlClient.SqlClient; - yield* sql` - INSERT INTO provider_session_runtime ( - thread_id, - provider_name, - provider_instance_id, - adapter_key, - status, - last_seen_at - ) - VALUES ( - ${"thread-opencode-runtime"}, - ${"opencode"}, - ${"opencode"}, - ${"opencode"}, - ${"ready"}, - ${"2026-08-25T00:00:00.000Z"} - ) - `; const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isTrue(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("persists explicit disables after a provider has been used", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* recordProviderUsage("grok"); - - assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); - - const settings = yield* serverSettings.updateSettings({ - providers: { grok: { enabled: false } }, - }); - assert.isFalse(settings.providers.grok.enabled); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.isFalse(JSON.parse(raw).providers.grok.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("persists explicit provider enables before their first use", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - yield* serverSettings.updateSettings({ - providers: { - cursor: { enabled: true }, - grok: { enabled: true }, - opencode: { enabled: true }, - }, - }); - yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - const persisted = JSON.parse(raw); - assert.isTrue(persisted.providers.cursor.enabled); - assert.isTrue(persisted.providers.grok.enabled); - assert.isTrue(persisted.providers.opencode.enabled); + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("keeps optional providers disabled after a new installation writes settings", () => + it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const initial = yield* serverSettings.getSettings; - assert.isFalse(initial.providers.grok.enabled); - assert.isFalse(initial.providers.opencode.enabled); - assert.isFalse(initial.providers.cursor.enabled); - const next = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "~/Development", providerInstances: { [ProviderInstanceId.make("grok")]: { driver: ProviderDriverKind.make("grok"), @@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); - assert.isFalse(next.providers.grok.enabled); - assert.isFalse(next.providers.opencode.enabled); - assert.isFalse(next.providers.cursor.enabled); const grok = next.providerInstances[ProviderInstanceId.make("grok")]; assert.isDefined(grok); assert.isFalse(resolveProviderInstanceEnabled(grok)); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - const persisted = JSON.parse(raw); - assert.isFalse(persisted.providers.cursor.enabled); - assert.isFalse(persisted.providers.grok.enabled); - assert.isFalse(persisted.providers.opencode.enabled); + const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.isUndefined(persisted.providers); assert.isUndefined(persisted.providerInstances.grok.enabled); }).pipe(Effect.provide(layerServerSettings())), ); @@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("trims provider path settings when updates are applied", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: " /opt/homebrew/bin/codex ", - homePath: " ", - }, - claudeAgent: { - binaryPath: " /opt/homebrew/bin/claude ", - }, - opencode: { - binaryPath: " /opt/homebrew/bin/opencode ", - serverUrl: " http://127.0.0.1:4096 ", - serverPassword: " secret-password ", - }, - }, - }); - - assert.deepEqual(next.providers.codex, { - enabled: true, - binaryPath: "/opt/homebrew/bin/codex", - homePath: "", - shadowHomePath: "", - launchArgs: "", - customModels: [], - }); - assert.deepEqual(next.providers.claudeAgent, { - enabled: true, - binaryPath: "/opt/homebrew/bin/claude", - homePath: "", - customModels: [], - launchArgs: "", - autoCompactWindow: "", - }); - assert.deepEqual(next.providers.opencode, { - // OpenCode is disabled by default; this update only touches paths. - enabled: false, - binaryPath: "/opt/homebrew/bin/opencode", - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - customModels: [], - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("trims observability settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("defaults blank binary paths to provider executables", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: " ", - }, - claudeAgent: { - binaryPath: "", - }, - }, - }); - - assert.equal(next.providers.codex.binaryPath, "codex"); - assert.equal(next.providers.claudeAgent.binaryPath, "claude"); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("writes non-default settings and explicit optional provider defaults to disk", () => + it.effect("writes only non-default settings to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - const next = yield* serverSettings.updateSettings({ + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - }, - }, automaticGitFetchInterval: Duration.seconds(10), }); - assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { addProjectBaseDirectory: "~/Development", @@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - cursor: { - enabled: false, - }, - grok: { - enabled: false, - }, - opencode: { - enabled: false, - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - }, - }, backgroundActivity: { schemaVersion: 1, profile: "custom", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7cbcd895ac14..0695f673650d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -23,6 +23,8 @@ import { type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, type ProviderInstanceMutation, + defaultInstanceIdForDriver, + isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson( }), ); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); -const PersistedOptionalProviderSettings = Schema.Struct({ - providers: Schema.optionalKey( - Schema.Struct({ - cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - }), - ), -}); -const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( - fromLenientJson(PersistedOptionalProviderSettings), +/** + * The retired `providers.` map, read without its old schemas. Before + * `providerInstances` existed each built-in driver had one blob there; it + * now only feeds `migrateLegacyProviderSettings`. + */ +const LegacyProviderSettingsJson = fromLenientJson( + Schema.Struct({ + providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + }), ); +const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); -function restoreUsedProviders( +// Drivers that start disabled, so a session in the history means the user +// turned them on before the instance kept an explicit flag. +const HISTORY_RESTORED_DRIVERS: ReadonlySet = new Set( + ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)), +); + +/** + * Move each customized legacy `providers.` blob into the driver's + * default `providerInstances` slot. An explicit instance already in that slot + * wins. Blobs that match a fresh install (empty, or only an `enabled` flag + * equal to the driver default) carry nothing and are dropped. + * + * A cursor/grok/opencode slot without an `enabled` flag is enabled when + * provider history shows the driver was used, which is how those drivers + * were opted into before they had an explicit flag. Callers run this only + * while the settings file still has the retired map, so it happens once. + */ +function migrateLegacyProviderSettings( settings: ServerSettings, - persisted: typeof PersistedOptionalProviderSettings.Type, + legacyProviders: Readonly>, providerHistory: ReadonlyArray<{ readonly providerName: string; readonly providerInstanceId: string | null; }>, ): ServerSettings { - const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); - const usedProviderInstances = new Set( + // History rows are raw SQL text; compare them as strings. + const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); + const usedProviderInstances = new Set( providerHistory.map( ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, ), ); - const providerInstances = Object.fromEntries( - Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ - instanceId, + + const providerInstances: Record = {}; + for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< + [ProviderInstanceId, ProviderInstanceConfig] + >) { + providerInstances[instanceId] = instance.enabled === undefined && - (instance.driver === "cursor" || - instance.driver === "grok" || - instance.driver === "opencode") && + HISTORY_RESTORED_DRIVERS.has(instance.driver) && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } - : instance, - ]), - ); + : instance; + } + + // History restores a never-configured cursor/grok/opencode slot too. + const legacyEntries = new Map(Object.entries(legacyProviders)); + for (const driver of HISTORY_RESTORED_DRIVERS) { + if (!legacyEntries.has(driver)) legacyEntries.set(driver, {}); + } + for (const [kind, blob] of legacyEntries) { + if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue; + if (Array.isArray(blob)) continue; + const driver = kind; + const instanceId = defaultInstanceIdForDriver(driver); + if (Object.hasOwn(providerInstances, instanceId)) continue; + + const { enabled: rawEnabled, ...config } = blob as Record; + const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined; + const enabled = + explicitEnabled ?? + (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined); + const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} }); + if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) { + continue; + } + providerInstances[instanceId] = { + driver, + ...(enabled === undefined ? {} : { enabled }), + config, + } satisfies ProviderInstanceConfig; + } return { ...settings, - providers: { - ...settings.providers, - cursor: { - ...settings.providers.cursor, - enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), - }, - grok: { - ...settings.providers.grok, - enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), - }, - opencode: { - ...settings.providers.opencode, - enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), - }, - }, providerInstances, }; } const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); +const TEXT_GENERATION_FALLBACK_DRIVERS = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "muse", + "pi", + "opencode", + "antigravity", +].map((driver) => ProviderDriverKind.make(driver)); + /** ACP Registry instances reject every application text-generation operation. */ function selectionSupportsTextGeneration( settings: ServerSettings, @@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - // Same precedence as isModelSelectionProviderEnabled: an explicit provider - // instance wins over the legacy providers map, which decodes to defaults - // (codex enabled) when the Providers UI has only written providerInstances. - const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { - const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; - return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); - }); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; + // The built-in default instances in preference order. A slot without an + // explicit instance uses its driver's default enabled state. + const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => + resolveProviderInstanceEnabled( + settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, + ), + ); if (!fallback) { return settings; } @@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "pullRequestMergeMethod", ]); -// Preserve both enabled states because provider history cannot recover a new opt-in. -const PERSISTED_SERVER_SETTINGS_DEFAULTS = { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, - grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, - opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, - }, -}; - function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -671,7 +702,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, ); return yield* writeFileStringAtomically({ @@ -740,7 +771,7 @@ const make = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; - let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + let legacyProviders: Readonly> | undefined; // A file that failed to decode must stay on disk for the user to repair; // the fold below only writes when it started from the file's real contents. let settingsFileTrusted = true; @@ -748,12 +779,12 @@ const make = Effect.gen(function* () { if (yield* readConfigExists) { const raw = yield* readRawConfig; const decoded = decodeServerSettingsJsonExit(raw); - const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); - if (persistedSettings._tag === "Success") { - persisted = persistedSettings.value; + const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); + if (legacySettings._tag === "Success") { + legacyProviders = legacySettings.value.providers; } - if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { - const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : legacySettings; settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { @@ -817,14 +848,18 @@ const make = Effect.gen(function* () { ); const loaded = foldProviderInstanceEnabledFlags( - restoreUsedProviders(settings, persisted, providerHistory), + legacyProviders === undefined + ? settings + : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), ); const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; - if (migrated !== loaded) { + // A file still carrying the retired `providers` map is rewritten once so + // the migrated instances persist and the old key disappears. + if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) { yield* writeSettingsAtomically(migrated); } return migrated; diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5a493cc28053..95819d285236 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2272,7 +2272,7 @@ it.layer( ), ); - it.effect("resolves the legacy Codex default instance", () => + it.effect("resolves the Codex default slot", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2283,18 +2283,22 @@ it.layer( env: undefined, }); - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); }).pipe( Effect.provide( ServerSettings.ServerSettingsService.layerTest({ - providerInstances: {}, - providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: "~/.codex-default" }, + }, + }, }), ), ), ); - it.effect("resolves the legacy Claude default instance", () => + it.effect("resolves the Claude default slot", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2305,18 +2309,22 @@ it.layer( env: undefined, }); - expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); }).pipe( Effect.provide( ServerSettings.ServerSettingsService.layerTest({ - providerInstances: {}, - providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: "~/.claude-default" }, + }, + }, }), ), ), ); - it.effect("prefers an explicit default instance over legacy provider settings", () => + it.effect("resolves an empty Codex default slot with default config", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2324,26 +2332,16 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", - env: undefined, + env: { CODEX_HOME: "/inherited/codex-home" }, }); - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + expect(environment.CODEX_HOME).toBe("/inherited/codex-home"); }).pipe( - Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ - providers: { codex: { homePath: "~/.codex-legacy" } }, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: "codex", - config: { homePath: "~/.codex-explicit" }, - }, - }, - }), - ), + Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), ), ); - it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ab5607df41f0..52e98f0eaf7d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -73,9 +73,15 @@ const setup = Effect.gen(function* () { home, transcript: NodePath.join(transcriptDir, "session.jsonl"), settings: { - providers: { - claudeAgent: { homePath: NodePath.join(home, "claude") }, - codex: { homePath: NodePath.join(home, "codex") }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: NodePath.join(home, "claude") }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: NodePath.join(home, "codex") }, + }, }, }, }; @@ -227,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { } describe("UsageService", () => { - it.live.each([ - { explicitDefault: true, label: "explicit" }, - { explicitDefault: false, label: "legacy" }, - ])( - "reads shared managed $label default and disabled extra account history once", - ({ explicitDefault }) => - Effect.gen(function* () { - const { home, settings } = yield* setup; - const summary = yield* Effect.gen(function* () { - for (const [id, output] of [ - ["codex", 17], - ["codex-personal", 23], - ] as const) { - const sessions = NodePath.join(home, "shared-codex", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(sessions, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(sessions, `${id}-rollout.jsonl`), - [ - { type: "session_meta", payload: { id } }, - { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, - }, + it.live("reads shared managed default and disabled extra account history once", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, }, - ] - .map((line) => encodeUnknownJsonString(line)) - .join("\n") + "\n", - ); - }); - } - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - // Scoped inside the state directory, so pending cache writes land - // before it is removed. - Effect.scoped, - Effect.provide( - layerService({ - prefix: "usage-managed-accounts", - home, - settings: { - ...settings, - providers: { - ...settings.providers, - codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, }, - providerInstances: { - ...(explicitDefault - ? { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - }, - }, - } - : {}), - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - shadowHomePath: NodePath.join(home, "personal-shadow"), - }, - environment: [ - { - name: "CODEX_HOME", - value: NodePath.join(home, "ignored-environment"), - sensitive: false, - }, - ], + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + // Scoped inside the state directory, so pending cache writes land + // before it is removed. + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], }, }, - }), - ), - ); - assert.strictEqual(totalOutputTokens(summary), 40); - assert.strictEqual( - summary.sources.filter( - (source) => source.fingerprint.provider === "codex" && source.status === "ok", - ).length, - 1, - ); - }).pipe(Effect.scoped), + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), ); it.live("omits Cursor account usage when no file login is saved", () => Effect.gen(function* () { @@ -788,6 +782,7 @@ describe("UsageService", () => { settings: { ...settings, providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claude-work")]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false, @@ -847,7 +842,7 @@ describe("UsageService", () => { ); it.live( - "uses explicit account settings before environment and legacy homes, then refreshes them", + "uses explicit account settings before environment and default homes, then refreshes them", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -884,6 +879,7 @@ describe("UsageService", () => { ); yield* settingsService.updateSettings({ providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: "" }, @@ -912,6 +908,7 @@ describe("UsageService", () => { settings: { ...settings, providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: configured }, @@ -1271,7 +1268,15 @@ describe("UsageService", () => { layerService({ prefix: "usage-service-cleanup-test", home, - settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, + settings: { + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: alias }, + }, + }, + }, ratesDocument: { "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, }, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 4ff2bbce9ef6..7b4269287f4b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -351,18 +351,15 @@ export const make = Effect.gen(function* () { }> = []; const seen = new Set(); for (const driver of ["claudeAgent", "codex", "grok"] as const) { - // Disabled accounts still have history. Explicit default slots replace - // the legacy settings, just as they do in the provider registry. + // Disabled accounts still have history. An unconfigured default slot + // runs with default config, just as it does in the provider registry. const instances: Array< Pick & { instanceId: ProviderInstanceId } > = Object.entries(settings.providerInstances) .filter(([, instance]) => instance.driver === driver) .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); if (!Object.hasOwn(settings.providerInstances, driver)) { - instances.push({ - config: settings.providers[driver], - instanceId: ProviderInstanceId.make(driver), - }); + instances.push({ instanceId: ProviderInstanceId.make(driver) }); } for (const instance of instances) { const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9a4549efc9fa..7607c2c7ddb4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -898,8 +898,7 @@ function OnboardingCodexSetup({ const settings = serverConfig.settings; const instance = settings.providerInstances[instanceId] ?? { driver: ProviderDriverKind.make("codex"), - enabled: settings.providers.codex.enabled, - config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex, + config: createdAccount ? { enabled: true, setupMode: "managed" } : {}, }; const mode = readCodexSetupMode(instance.config); const existingChosen = @@ -915,8 +914,6 @@ function OnboardingCodexSetup({ patch: buildProviderInstanceUpdatePatch({ settings, instanceId, - driver: ProviderDriverKind.make("codex"), - isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), instance: { ...instance, enabled: true, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index e923a081b6e4..91116cb0b6f0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { - DEFAULT_UNIFIED_SETTINGS, type AcpRegistrySearchAgent, AuthProvidersManageScope, ProviderInstanceId, @@ -21,7 +20,6 @@ import { useEnvironmentSettings, usePersistEnvironmentProviderInstanceMutation, } from "../../hooks/useSettings"; -import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; @@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({ const [isSaving, setIsSaving] = useState(false); const [createdInstanceId, setCreatedInstanceId] = useState(null); - const existingIds = useMemo(() => { - const ids = new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]); - const defaults = DEFAULT_UNIFIED_SETTINGS.providers as Record; - // Reserve configured legacy slots too, so adding an account cannot replace them. - for (const [kind, config] of Object.entries(settings.providers ?? {})) { - if (!Equal.equals(config, defaults[kind])) ids.add(kind); - } - return ids; - }, [settings.providerInstances, settings.providers]); + // Codex and Claude run at their default slots before they are configured, so + // those ids stay reserved; other unconfigured default slots are free to take. + const existingIds = useMemo( + () => new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]), + [settings.providerInstances], + ); const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index bcce41229b2e..35a4bcbd33a9 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -276,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => { it("keeps legacy provider configuration visible when disabled", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, - providers: { - ...DEFAULT_UNIFIED_SETTINGS.providers, - grok: { - ...DEFAULT_UNIFIED_SETTINGS.providers.grok, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), enabled: false, - binaryPath: "/custom/grok", + config: { + binaryPath: "/custom/grok", + }, }, }, }; @@ -516,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => { const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); - expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providers"]); - expect(resetPatch).not.toHaveProperty("favorites"); - expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + // Removing the instance is the whole reset; shared preferences stay untouched. + expect(resetPatch ?? {}).toEqual({}); }); it("updates one provider instance without sending a stale whole map", async () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index dd471b4d14e8..461277127ac8 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -147,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null { const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ provider: definition.driverKind, + hasDefaultInstance: definition.hasDefaultInstance !== false, })); function configuredBinaryPath(config: unknown): string { @@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({ ); for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; const driver = providerSettings.provider; const defaultInstanceId = defaultInstanceIdForDriver(driver); const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - // A remote device may run a server version whose settings predate this - // driver, so the legacy mirror can be absent. Without either an explicit - // instance or a legacy blob there is nothing to render for the slot. - const legacyConfig = legacyProviders[providerSettings.provider]; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; - // The envelope is the single enabled flag: keep the legacy in-config - // flag out of the synthesized blob, or an explicit `enabled: false` - // would keep winning over the envelope and the Switch could never - // turn a default-off provider on. - const synthesizedInstance = (): ProviderInstanceConfig | undefined => { - if (legacyConfig === undefined) { - return undefined; - } - const { enabled: legacyEnabled, ...legacyConfigRest } = legacyConfig; - return { - driver, - enabled: legacyEnabled, - config: legacyConfigRest, - } satisfies ProviderInstanceConfig; - }; - const effectiveInstance: ProviderInstanceConfig | undefined = - explicitInstance ?? synthesizedInstance(); - // Only the default slot depends on the legacy blob; custom instances for - // the driver must still render even when the slot has nothing to show. - if (effectiveInstance !== undefined) { - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - if ( - driver === "codex" || + // An unconfigured default slot runs with the driver's default config. + const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver }; + const isDirty = explicitInstance !== undefined; + // Drivers without a default instance list only their configured instances. + const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined; + if ( + hasDefaultSlot && + (driver === "codex" || driver === "claudeAgent" || isDirty || resolveProviderInstanceEnabled(effectiveInstance) || - defaultInstanceId === targetInstanceId - ) { - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - } + defaultInstanceId === targetInstanceId) + ) { + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); } for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { if (id === defaultInstanceId) continue; @@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({ settings, instanceId: row.instanceId, instance: next, - driver: row.driver, - isDefault: row.isDefault, textGenerationModelSelection: options?.textGenerationModelSelection, }); const result = await persistProviderInstance( @@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({ }; const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - const result = await persistProviderInstance( - { operation: "remove", instanceId: defaultInstanceId }, - { - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - }, - ); + const result = await persistProviderInstance({ + operation: "remove", + instanceId: defaultInstanceIdForDriver(driverKind), + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 3f9d233ed278..fe26ce239393 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => { }); describe("buildProviderInstanceUpdatePatch", () => { - it("promotes an edited default provider into providerInstances and resets the legacy provider", () => { + it("replaces one instance and keeps the others", () => { const instanceId = ProviderInstanceId.make("codex"); - const nextInstance = { + const otherId = ProviderInstanceId.make("codex_personal"); + const other = { driver: ProviderDriverKind.make("codex"), - enabled: true, - config: { - binaryPath: "/opt/t3/codex", - }, + config: { homePath: "/Users/example/.codex-personal" }, } satisfies ProviderInstanceConfig; - - const patch = buildProviderInstanceUpdatePatch({ - settings: { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/legacy/codex", - }, - }, - }, - instanceId, - instance: nextInstance, - driver: ProviderDriverKind.make("codex"), - isDefault: true, - }); - - expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); - expect(patch.providers?.codex).toEqual(DEFAULT_SERVER_SETTINGS.providers.codex); - }); - - it("updates custom instances without touching legacy provider settings", () => { - const instanceId = ProviderInstanceId.make("codex_personal"); const nextInstance = { driver: ProviderDriverKind.make("codex"), enabled: true, - config: { - homePath: "/Users/example/.codex-personal", - }, + config: { binaryPath: "/opt/t3/codex" }, } satisfies ProviderInstanceConfig; const patch = buildProviderInstanceUpdatePatch({ - settings: DEFAULT_SERVER_SETTINGS, + settings: { providerInstances: { [otherId]: other } }, instanceId, instance: nextInstance, - driver: ProviderDriverKind.make("codex"), - isDefault: false, }); - expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); - expect(patch.providers).toBeUndefined(); + expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance }); + expect(patch).not.toHaveProperty("textGenerationModelSelection"); }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d5c6359c1c9f..232357b5482f 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,7 +1,6 @@ import type { BackgroundActivityProfile, BackgroundActivitySettings, - ProviderDriverKind, ProviderInstanceConfig, PreviewViewportSetting, ProviderInstanceId, @@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: { } export function buildProviderInstanceUpdatePatch(input: { - readonly settings: Pick; + readonly settings: Pick; readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; readonly textGenerationModelSelection?: | ServerSettings["textGenerationModelSelection"] | undefined; }): Partial { - type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const legacyProviderDefault = input.isDefault ? legacyProviderDefaults[input.driver] : undefined; return { - ...(legacyProviderDefault !== undefined - ? { - providers: { - ...input.settings.providers, - [input.driver]: legacyProviderDefault, - } as ServerSettings["providers"], - } - : {}), providerInstances: { ...input.settings.providerInstances, [input.instanceId]: input.instance, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index bcd9e59519bc..cf1bf389dd19 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => { const nativeModel = "gemini-3.1-pro"; const settings: UnifiedSettings = { ...DEFAULT_UNIFIED_SETTINGS, - providers: { - ...DEFAULT_UNIFIED_SETTINGS.providers, - antigravity: { - ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity, - customModels: ["api-only-model"], - }, - }, providerInstances: { + [ProviderInstanceId.make("antigravity")]: { + driver, + config: { customModels: ["api-only-model"] }, + }, [customId]: { driver, config: { customModels: ["unknown-model"] } }, }, }; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 91e7251f35fe..1ffd3b4be920 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -66,15 +66,7 @@ function readInstanceCustomModels( return readCustomModelEntries(value); } } - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - if (instanceId !== defaultInstanceId) { - return []; - } - const legacyProviders = settings.providers as Record< - string, - { readonly customModels: ReadonlyArray } | undefined - >; - return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []); + return []; } export interface AppModelOption { @@ -228,11 +220,8 @@ function getAppModelOptions( * come from the instance's own `entry.models` snapshot (rather than the * first-matching-kind fallback in `getProviderModels`), so each custom * instance gets the precise model list its driver reported. Custom model - * slugs come from the instance's own `providerInstances[id].config.customModels` - * when present, falling back to the legacy per-kind - * `settings.providers[driverKind].customModels` bucket for default - * instances only. This keeps two instances of the same kind from leaking - * custom slugs into each other. Custom rows reported by the server are + * slugs come from the instance's own `providerInstances[id].config.customModels`, + * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are * ignored so a slug removed in Settings disappears without waiting for the * next provider probe. */ diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index b0b3a4d57515..17cb5ca836ab 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, }, }, }, @@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { provider, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - claudeAgent: { - ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, - binaryPath: "/opt/Claude Tools/$current/claude", + providerInstances: { + [ProviderInstanceId.make("claude")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { + binaryPath: "/opt/Claude Tools/$current/claude", + }, }, }, }, @@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath, + }, }, }, }, @@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/opt/codex\\work/codex", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "/opt/codex\\work/codex", + }, }, }, }, @@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "C:\\Tools\\codex.exe", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "C:\\Tools\\codex.exe", + }, }, }, }, @@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/opt/Codex Tools/codex", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "/opt/Codex Tools/codex", + }, }, }, }, diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index 08dd69bd3b03..ae75ace7668b 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -112,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand( const instance = settings.providerInstances[provider.instanceId]; if (provider.driver === "claudeAgent") { - const config = decodeClaudeSettings( - instance ? (instance.config ?? {}) : settings.providers.claudeAgent, - ); + const config = decodeClaudeSettings(instance?.config ?? {}); const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; } if (provider.driver === "codex") { - const config = decodeCodexSettings( - instance ? (instance.config ?? {}) : settings.providers.codex, - ); + const config = decodeCodexSettings(instance?.config ?? {}); const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 4ff23880ade6..236b4363a026 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.isDefault).toBe(true); expect(entry?.enabled).toBe(false); }); - it("uses legacy settings for a built-in default instance", () => { + it("uses the driver default for an unconfigured built-in default instance", () => { const entries = deriveProviderInstanceEntries([ - provider({ - provider: ProviderDriverKind.make("codex"), - instanceId: "codex", - }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), + provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - providers: { codex: { enabled: false } } as never, - }); + const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); - expect(entry?.enabled).toBe(false); + // Settings decide over a stale probe: Codex starts on, Grok starts off. + expect(codex?.enabled).toBe(true); + expect(grok?.enabled).toBe(false); }); }); @@ -261,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => { agentId, [snapshot], { - providers: {} as never, providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("acpRegistry"), @@ -629,7 +620,6 @@ describe("provider icon metadata", () => { const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; const brandedEntries = applyProviderInstanceSettings(snapshots, { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - providers: {} as never, }); expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); @@ -642,7 +632,6 @@ describe("provider icon metadata", () => { config: { ...registryConfig, source: "local", commandPath: "dsh" }, }, }, - providers: {} as never, }); expect(localEntry?.acpRegistryAgentId).toBeUndefined(); expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); @@ -651,7 +640,6 @@ describe("provider icon metadata", () => { const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - providers: {} as never, }); expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 60630c5f826b..d6c6c5e2a945 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -15,6 +15,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, + isUnconfiguredDefaultInstanceEnabled, resolveProviderInstanceEnabled, type ModelSelection, type ProviderDriverKind, @@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries( */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable< - readonly [ - string, - ReadonlyArray, - Pick?, - ] + readonly [string, ReadonlyArray, Pick?] >, ): ReadonlyMap> { const byEnvironment = new Map>(); @@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment( * settings write, so picker visibility must follow settings rather than waiting * for probe reconciliation. * - * Only built-in default instances have a legacy `providers` entry. Every - * other instance exists through `providerInstances`; if it is absent there, - * its streamed snapshot is stale (for example immediately after deletion) - * and is treated as disabled. + * A built-in default instance without a `providerInstances` entry keeps its + * streamed state, since the server runs it with default config. Any other + * instance missing from `providerInstances` is stale (for example + * immediately after deletion) and is treated as disabled. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, - settings: Pick, + settings: Pick, ): ReadonlyArray { - const legacyProviders = settings.providers as Readonly< - Record - >; - return entries.map((entry) => { const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) ? settings.providerInstances[entry.instanceId] : undefined; - const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind) - ? legacyProviders[entry.driverKind] - : undefined; const enabled = explicitInstance ? resolveProviderInstanceEnabled(explicitInstance) - : entry.isDefault && legacyProvider - ? (legacyProvider.enabled ?? entry.enabled) + : entry.isDefault + ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) : false; if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { return enabled === entry.enabled ? entry : { ...entry, enabled }; diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index d11c0e367d1f..e0100be873b3 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -103,8 +103,9 @@ produce catches them. ## Where a driver plugs in -- **Contracts:** settings schema and patch, default model, and display name in - [`packages/contracts`](../../packages/contracts/src). New providers are off by default. +- **Contracts:** settings schema, default model, and display name in + [`packages/contracts`](../../packages/contracts/src). Instances store that config in + `providerInstances`. New providers are off by default. - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 742066e9efa0..58e8fa5d9658 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -208,17 +208,6 @@ describe("custom model settings", () => { { slug: "named", name: "Named", capabilities }, ]); }); - - it("accepts entries at the settings patch boundary", () => { - expect( - decodeServerSettingsPatch({ - providers: { codex: { customModels: [{ slug: "x", capabilities }] } }, - }).providers?.codex?.customModels, - ).toEqual([{ slug: "x", capabilities }]); - expect(() => - decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }), - ).toThrow(); - }); }); describe("ClaudeSettings auto-compaction", () => { @@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => { expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); }, ); - - it("rejects an unsupported threshold at the settings patch boundary", () => { - expect(() => - decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }), - ).toThrow(); - expect( - decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }), - ).toBeDefined(); - }); }); describe("ClientSettings notifications", () => { @@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { const decoded = decodeServerSettings({}); expect(decoded.providerInstances).toEqual({}); - // Legacy `providers` struct is still hydrated with its per-driver defaults - // so existing call sites keep working through the migration. - expect(decoded.providers.codex.enabled).toBe(true); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { @@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { describe("provider enabled defaults", () => { it("keeps Muse disabled until a configured instance opts in", () => { const muse = ProviderDriverKind.make("muse"); - expect(decodeServerSettings({}).providers.muse.enabled).toBe(false); expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true); expect( @@ -812,26 +788,13 @@ describe("provider enabled defaults", () => { }); it("enables only the stable bindings by default", () => { - const decoded = decodeServerSettings({}); - expect(decoded.providers.codex.enabled).toBe(true); - expect(decoded.providers.claudeAgent.enabled).toBe(true); - expect(decoded.providers.cursor.enabled).toBe(false); - expect(decoded.providers.grok.enabled).toBe(false); - expect(decoded.providers.opencode.enabled).toBe(false); - }); - - it("keeps Cursor enabled when an existing user explicitly opted in", () => { - const cursor = ProviderDriverKind.make("cursor"); - const cursorId = ProviderInstanceId.make("cursor"); - const decoded = decodeServerSettings({ - providers: { cursor: { enabled: true } }, - providerInstances: { - [cursorId]: { driver: cursor, enabled: true, config: {} }, - }, - }); - - expect(decoded.providers.cursor.enabled).toBe(true); - expect(resolveProviderInstanceEnabled(decoded.providerInstances[cursorId]!)).toBe(true); + const enabledByDefault = (driver: string) => + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} }); + expect(enabledByDefault("codex")).toBe(true); + expect(enabledByDefault("claudeAgent")).toBe(true); + for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) { + expect(enabledByDefault(driver)).toBe(false); + } }); it("resolves instance enabled state with explicit false winning", () => { @@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => { }); }); -describe("ServerSettings Cursor legacy settings", () => { - it("preserves V1 Cursor CLI settings when reading and writing shared settings", () => { - const decoded = decodeServerSettings({ - providers: { - cursor: { - enabled: true, - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }, - }, - }); - - expect(decoded.providers.cursor.enabled).toBe(true); - expect(encodeServerSettings(decoded).providers?.cursor).toMatchObject({ - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }); - }); - - it("ignores obsolete Cursor CLI settings in patches", () => { - const patch = decodeServerSettingsPatch({ - providers: { - cursor: { - enabled: true, - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }, - }, - }); - - expect(patch.providers?.cursor?.enabled).toBe(true); - expect(patch.providers?.cursor).not.toHaveProperty("binaryPath"); - expect(patch.providers?.cursor).not.toHaveProperty("apiEndpoint"); - }); -}); - describe("ServerSettings.sourceControlWritingStyle", () => { it("defaults all style settings for legacy configs", () => { const settings = decodeServerSettings({}); @@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", }, - providers: { - codex: { - binaryPath: " /opt/homebrew/bin/codex ", - homePath: " ~/.codex ", - launchArgs: " --strict-config --enable foo ", - }, - }, providerInstances: { codex_personal: { driver: " codex ", @@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.addProjectBaseDirectory).toBe("~/Development"); expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); - expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); - expect(patch.providers?.codex?.homePath).toBe("~/.codex"); - expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); @@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => { const encoded = encodeServerSettings({ ...defaultSettings, addProjectBaseDirectory: " ~/Development ", - providers: { - ...defaultSettings.providers, - codex: { - ...defaultSettings.providers.codex, - binaryPath: " /opt/homebrew/bin/codex ", - launchArgs: " --strict-config ", - }, - }, }); expect(encoded.addProjectBaseDirectory).toBe("~/Development"); - expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); - expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b93a1d354746..9cce40c70ce2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -40,7 +40,7 @@ import { import { ProviderInstanceConfig, ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; @@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), - // Legacy single-instance-per-driver settings. Continues to be the source - // of truth until `providerInstances` (below) lands per-driver migration - // shims and the server starts hydrating instances from it. Driver-specific - // schemas live here for the duration of the migration; once each driver - // owns its config in its own package, this struct shrinks to nothing and - // is removed entirely. - providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - muse: MuseSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob // is `Schema.Unknown` at this layer so envelopes with unknown drivers @@ -1512,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS /** * Read the legacy `enabled` flag embedded in a provider instance config * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single - * flag going forward; this reader exists for legacy `providers.` - * blobs and old settings files that still carry the flag in-config. + * flag going forward; this reader exists for old settings files that still + * carry the flag in-config. */ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { if (config === null || typeof config !== "object" || Array.isArray(config)) { @@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un }; /** - * Default enabled state for a built-in driver when neither the envelope nor - * the config blob carries a flag. Derived from the driver's settings schema - * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays - * the single source of truth. Unknown (fork) drivers default to enabled. + * Built-in drivers that stay off until the user turns them on. Matches the + * `enabled` decoding default of each driver's settings schema. */ -const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { - const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< - string, - { readonly enabled?: boolean } | undefined - >; - return legacyDefaults[driver]?.enabled ?? true; -}; +const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( + ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => + ProviderDriverKind.make(driver), + ), +); + +/** Built-in drivers whose default instance runs before the user configures it. */ +const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet = new Set( + ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)), +); + +/** + * Whether the built-in default instance at `instanceId` is enabled while + * settings have no `providerInstances` entry for it. Only Codex and Claude + * start on; any other id without an entry has no running instance. + */ +export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean => + DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId); + +/** + * Default enabled state for a driver when neither the envelope nor the config + * blob carries a flag. Unknown (fork) drivers default to enabled. + */ +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => + !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver); /** * Resolve whether a configured provider instance is enabled. An explicit @@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({ options: Schema.optionalKey(ProviderOptionSelections), }); -const CodexSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - homePath: Schema.optionalKey(TrimmedString), - shadowHomePath: Schema.optionalKey(TrimmedString), - launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const ClaudeSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - homePath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), - launchArgs: Schema.optionalKey(TrimmedString), - // Validated at the patch boundary so a typo fails the one update with a - // schema error instead of a generic whole-settings failure. - autoCompactWindow: Schema.optionalKey( - TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), - ), -}); - -const CursorSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const GrokSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const AntigravitySettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - authMethod: Schema.optionalKey(AntigravityAuthMethod), - apiKey: Schema.optionalKey(TrimmedString), - gcpProject: Schema.optionalKey(TrimmedString), - gcpLocation: Schema.optionalKey(TrimmedString), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const MuseSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const PiSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const OpenCodeSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - serverUrl: Schema.optionalKey(TrimmedString), - serverPassword: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - export const ServerSettingsPatch = Schema.Struct({ worktreeCleanup: Schema.optionalKey( Schema.NullOr( @@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({ tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), }), ), - providers: Schema.optionalKey( - Schema.Struct({ - codex: Schema.optionalKey(CodexSettingsPatch), - claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), - cursor: Schema.optionalKey(CursorSettingsPatch), - grok: Schema.optionalKey(GrokSettingsPatch), - muse: Schema.optionalKey(MuseSettingsPatch), - pi: Schema.optionalKey(PiSettingsPatch), - opencode: Schema.optionalKey(OpenCodeSettingsPatch), - antigravity: Schema.optionalKey(AntigravitySettingsPatch), - }), - ), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index ed91c24d8833..88e46b937d13 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -58,6 +58,12 @@ export interface ProviderDriverMetadata { * rejects multi-instance configurations with a clear error. */ readonly supportsMultipleInstances?: boolean; + /** + * Whether the server runs an instance at `defaultInstanceIdForDriver(kind)` + * even when settings have no entry for it. Defaults to `true`; drivers that + * need user input before they can run (such as the ACP Registry) opt out. + */ + readonly hasDefaultInstance?: boolean; } export type ProviderWorkspaceSnapshot = ServerProvider & @@ -188,11 +194,8 @@ export interface ProviderDriver { */ readonly configSchema: Schema.Codec; /** - * Default config payload used when the legacy - * `ServerSettings.providers.` entry is empty or when the driver - * is auto-bootstrapped without user configuration. Returning a typed - * default keeps the migration path simple — no special-casing needed - * to construct a "blank" instance. + * Config used when an instance has no config blob, such as the default + * instance of a driver the user never configured. */ readonly defaultConfig: () => Config; /** diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index dcde2af1f30a..9c19ab91a61b 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, ProjectId, + ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => { it("keeps the environment text generation model when the override's provider is disabled", () => { const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - providers: { claudeAgent: { enabled: false } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + }, projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: disabledSelection }, }, @@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => { it("keeps the environment default model when the override's provider is disabled", () => { const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - providers: { claudeAgent: { enabled: false } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + }, projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, }); const resolved = resolveProjectSettings(settings, projectId); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 5e66b0ebe332..99b0f2c336c1 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,13 +1,12 @@ import { - isProviderDriverKind, isProviderAvailable, + isUnconfiguredDefaultInstanceEnabled, resolveProviderInstanceEnabled, isProviderTextGenerationCapable, type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, - type ProviderDriverKind, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -59,14 +58,6 @@ export function resolveProjectAutoPull( ); } -type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - -const getLegacyProviderSettings = ( - settings: ServerSettings, - provider: ProviderDriverKind, -): LegacyProviderSettings | undefined => - (settings.providers as Record)[provider]; - export function isModelSelectionProviderEnabled( settings: ServerSettings, selection: ModelSelection, @@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled( return resolveProviderInstanceEnabled(instanceConfig); } - return ( - isProviderDriverKind(selection.instanceId) && - getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true - ); + return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } export function resolveSourceControlWriterModelSelection( From 9b0df1358f9f9c3277bcb33777c2435b88ee75c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 14:18:17 -0700 Subject: [PATCH 025/377] refactor(provider-pi): move Pi into its own provider package (#17302) Co-authored-by: Claude Opus 5.5 --- apps/mobile/package.json | 2 + apps/mobile/src/components/ProviderIcon.tsx | 23 ++-- apps/mobile/src/lib/providerClients.ts | 11 ++ apps/server/package.json | 1 + .../scripts/record-grok-acp-replay-fixture.ts | 2 +- .../scripts/record-pi-rpc-replay-fixture.ts | 2 +- ...OrchestratorMcpToolkit.integration.test.ts | 2 +- .../Adapters/AcpAdapterV2.test.ts | 2 +- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 2 +- .../Adapters/ClaudeAdapterV2.test.ts | 2 +- .../Adapters/ClaudeAdapterV2.ts | 2 +- .../Adapters/CodexAdapterV2.test.ts | 4 +- .../Adapters/CodexAdapterV2.ts | 4 +- .../Adapters/GrokAdapterV2.testkit.ts | 2 +- .../Adapters/GrokAdapterV2.ts | 2 +- .../Adapters/MuseAdapterV2.test.ts | 2 +- .../Adapters/MuseAdapterV2.testkit.ts | 2 +- .../Adapters/MuseAdapterV2.ts | 2 +- .../Adapters/OpenCode2AdapterV2.test.ts | 4 +- .../Adapters/OpenCode2AdapterV2.ts | 2 +- .../Adapters/PiAdapterV2.testkit.ts | 2 +- .../ContextHandoffBudget.test.ts | 2 +- .../ContextHandoffDelivery.ts | 6 +- .../orchestration-v2/ContextHandoffService.ts | 2 +- .../OpenCode2OrchestratorV2.live.test.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 2 +- .../ProviderContinuationService.test.ts | 2 +- .../ProviderContinuationService.ts | 2 +- .../ProviderTurnStartService.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- .../src/orchestration-v2/runtimeLayer.ts | 2 +- .../testkit/ProviderReplayHarness.ts | 2 +- .../ProviderSwitch.integration.test.ts | 2 +- .../src/provider/AntigravityProvider.ts | 2 +- .../src/provider/Drivers/AcpRegistryDriver.ts | 4 +- .../src/provider/Drivers/AntigravityDriver.ts | 2 +- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../src/provider/Drivers/CodexDriver.ts | 4 +- .../provider/Drivers/CodexManagedProvider.ts | 2 +- .../src/provider/Drivers/CursorDriver.ts | 4 +- .../server/src/provider/Drivers/GrokDriver.ts | 4 +- .../server/src/provider/Drivers/MuseDriver.ts | 6 +- .../src/provider/Drivers/OpenCodeDriver.ts | 4 +- ...viderOrchestrationAdapterInfrastructure.ts | 2 +- apps/server/src/provider/builtInDrivers.ts | 2 +- apps/web/package.json | 1 + apps/web/src/components/Icons.tsx | 15 --- .../components/chat/ProviderInstanceIcon.tsx | 28 ++++- .../components/chat/ProviderPackageIcon.tsx | 47 ++++++++ .../src/components/chat/V2LifecycleRow.tsx | 13 +-- .../components/settings/providerDriverMeta.ts | 8 +- docs/internals/adding-a-provider.md | 34 ++++-- docs/internals/providers.md | 2 +- package.json | 2 +- packages/contracts/src/settings.ts | 39 +------ packages/provider-core/package.json | 16 +++ packages/provider-core/src/client.ts | 17 +++ .../src/server/continuationRequests.ts | 2 +- .../provider-core/src/server/handoffBudget.ts | 0 .../src/server/managedProvider.test.ts | 105 ++++++------------ .../src/server/managedProvider.ts | 9 +- .../src/server/snapshotSettings.ts | 2 +- packages/provider-pi/package.json | 36 ++++++ packages/provider-pi/src/client.ts | 27 +++++ packages/provider-pi/src/server.ts | 8 ++ .../provider-pi/src/server/adapter.test.ts | 8 +- .../provider-pi/src/server/adapter.ts | 12 +- .../provider-pi/src/server/commands.test.ts | 2 +- .../provider-pi/src/server/commands.ts | 0 .../provider-pi/src/server/driver.test.ts | 24 ++-- .../provider-pi/src/server/driver.ts | 18 ++- .../src/server/mcpExtensionSource.test.ts | 2 +- .../src/server/mcpExtensionSource.ts | 0 .../src/server/mcpInjection.test.ts | 4 +- .../provider-pi/src/server/mcpInjection.ts | 2 +- .../provider-pi/src/server/rpc.ts | 0 .../provider-pi/src/server/status.test.ts | 2 +- .../provider-pi/src/server/status.ts | 13 +-- .../src/server/textGeneration.test.ts | 2 +- .../provider-pi/src/server/textGeneration.ts | 10 +- .../src/server/thinkingCapabilities.test.ts | 2 +- .../src/server/thinkingCapabilities.ts | 0 packages/provider-pi/src/settings.ts | 47 ++++++++ packages/provider-pi/tsconfig.json | 7 ++ pnpm-lock.yaml | 43 +++++++ scripts/release-smoke.ts | 1 + 86 files changed, 475 insertions(+), 280 deletions(-) create mode 100644 apps/mobile/src/lib/providerClients.ts create mode 100644 apps/web/src/components/chat/ProviderPackageIcon.tsx rename apps/server/src/orchestration-v2/ProviderContinuationRequests.ts => packages/provider-core/src/server/continuationRequests.ts (97%) rename apps/server/src/orchestration-v2/ContextHandoffBudget.ts => packages/provider-core/src/server/handoffBudget.ts (100%) rename apps/server/src/provider/makeManagedServerProvider.test.ts => packages/provider-core/src/server/managedProvider.test.ts (88%) rename apps/server/src/provider/makeManagedServerProvider.ts => packages/provider-core/src/server/managedProvider.ts (97%) rename apps/server/src/provider/providerUpdateSettings.ts => packages/provider-core/src/server/snapshotSettings.ts (94%) create mode 100644 packages/provider-pi/package.json create mode 100644 packages/provider-pi/src/client.ts create mode 100644 packages/provider-pi/src/server.ts rename apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts => packages/provider-pi/src/server/adapter.test.ts (99%) rename apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts => packages/provider-pi/src/server/adapter.ts (99%) rename apps/server/src/provider/PiCommands.test.ts => packages/provider-pi/src/server/commands.test.ts (99%) rename apps/server/src/provider/PiCommands.ts => packages/provider-pi/src/server/commands.ts (100%) rename apps/server/src/provider/Drivers/PiDriver.test.ts => packages/provider-pi/src/server/driver.test.ts (92%) rename apps/server/src/provider/Drivers/PiDriver.ts => packages/provider-pi/src/server/driver.ts (93%) rename apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts => packages/provider-pi/src/server/mcpExtensionSource.test.ts (99%) rename apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts => packages/provider-pi/src/server/mcpExtensionSource.ts (100%) rename apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts => packages/provider-pi/src/server/mcpInjection.test.ts (98%) rename apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts => packages/provider-pi/src/server/mcpInjection.ts (99%) rename apps/server/src/orchestration-v2/Adapters/PiRpc.ts => packages/provider-pi/src/server/rpc.ts (100%) rename apps/server/src/provider/PiProvider.test.ts => packages/provider-pi/src/server/status.test.ts (97%) rename apps/server/src/provider/PiProvider.ts => packages/provider-pi/src/server/status.ts (98%) rename apps/server/src/textGeneration/PiTextGeneration.test.ts => packages/provider-pi/src/server/textGeneration.test.ts (98%) rename apps/server/src/textGeneration/PiTextGeneration.ts => packages/provider-pi/src/server/textGeneration.ts (93%) rename apps/server/src/provider/piThinkingCapabilities.test.ts => packages/provider-pi/src/server/thinkingCapabilities.test.ts (95%) rename apps/server/src/provider/piThinkingCapabilities.ts => packages/provider-pi/src/server/thinkingCapabilities.ts (100%) create mode 100644 packages/provider-pi/src/settings.ts create mode 100644 packages/provider-pi/tsconfig.json diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b8d8dea0d058..35fec0c2769a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -69,6 +69,8 @@ "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", + "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index f833baacd7c3..1675b15461d0 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./AppText"; +import { getProviderClient } from "../lib/providerClients"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -131,16 +132,20 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "pi") { - const foreground = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + const packageIcon = getProviderClient(props.provider)?.icon; + if (packageIcon) { + const themeFill = (fill: { readonly light: string; readonly dark: string }) => + isDarkMode ? fill.dark : fill.light; return ( - - - + + {packageIcon.paths.map((path) => ( + + ))} ); } diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts new file mode 100644 index 000000000000..c8c1c05c0158 --- /dev/null +++ b/apps/mobile/src/lib/providerClients.ts @@ -0,0 +1,11 @@ +import { isProviderDriverKind } from "@t3tools/contracts"; +import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { piClient } from "@t3tools/provider-pi/client"; + +/** The provider client definitions this mobile build ships. */ +const providerClients = makeProviderClientRegistry([piClient]); + +/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ +export function getProviderClient(driver: string | null | undefined) { + return isProviderDriverKind(driver) ? providerClients.get(driver) : undefined; +} diff --git a/apps/server/package.json b/apps/server/package.json index 74bb7cac0e94..7672fbf534e3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@opencode/protocol": "2.0.23", "@opencode/schema": "2.0.23", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-pi": "workspace:*", "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index 5623b45eda8c..a6b88d3024ff 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -34,7 +34,7 @@ import { import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; -import * as ProviderContinuationRequests from "../src/orchestration-v2/ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; diff --git a/apps/server/scripts/record-pi-rpc-replay-fixture.ts b/apps/server/scripts/record-pi-rpc-replay-fixture.ts index 8ea04f8ada62..560565666e90 100644 --- a/apps/server/scripts/record-pi-rpc-replay-fixture.ts +++ b/apps/server/scripts/record-pi-rpc-replay-fixture.ts @@ -29,7 +29,7 @@ import { PiOrchestratorReplayHarness, } from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; import * as PiAdapterV2Testkit from "../src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts"; -import { PI_PROVIDER } from "../src/orchestration-v2/Adapters/PiAdapterV2.ts"; +import { PI_PROVIDER } from "@t3tools/provider-pi/server"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 496c6260d9b4..f4aa8bafbe66 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -64,7 +64,7 @@ import { type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c6ad1fe57e71..c90826527176 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -71,7 +71,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { AcpProviderCapabilitiesV2, acpProviderItemNativeId, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 0c8332743b15..2767e0e1b41d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -100,7 +100,7 @@ import { } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { type BackgroundWork, type BackgroundWorkReport, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5273c37d7d68..ebabc11d69ee 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -67,7 +67,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 17876e430879..0b33327b1850 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -132,7 +132,7 @@ import { type ProviderAdapterDriverCreateInput, } from "@t3tools/provider-core/server/adapterDriver"; import { type BackgroundWorkReport, backgroundWorkNotification } from "../Notification.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index cadf66661143..4188c3248a5f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,6 +1,6 @@ import * as NodeOS from "node:os"; -import { historyResponseItems } from "../ContextHandoffBudget.ts"; +import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderTextDeltaCoalescer, @@ -70,7 +70,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f87525e9c8f7..660e690b5887 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,5 +1,5 @@ import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; -import { historyResponseItems } from "../ContextHandoffBudget.ts"; +import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { mcpToolPresentation, @@ -125,7 +125,7 @@ import { IdAllocatorV2, type IdAllocatorV2Shape } from "@t3tools/provider-core/s import { type ProviderContinuationRequest, ProviderContinuationRequests, -} from "../ProviderContinuationRequests.ts"; +} from "@t3tools/provider-core/server/continuationRequests"; import { backgroundWorkNotification } from "../Notification.ts"; import { makeProviderFailure, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 30438a421408..b770efd77136 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -17,7 +17,7 @@ import { } from "../../provider/acp/GrokAcpSupport.ts"; import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index 5f28bd1b419f..de3c77a696ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -58,7 +58,7 @@ import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts" import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts index a896f876d1ad..45a5e00d9bb1 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts @@ -41,7 +41,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; const testLayer = Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 182b67c20aad..cce14853f6fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -37,7 +37,7 @@ import { } from "../../provider/museSdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeReplayServerConfig, type OrchestratorV2ProviderReplayHarness, diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts index 7384afad822a..39d332e4e1b0 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts @@ -96,7 +96,7 @@ import { type ProviderAdapterV2TurnMessage, } from "@t3tools/provider-core/server/ProviderAdapter"; import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; -import type * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 1be594002791..96b28d5e082e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -44,8 +44,8 @@ import type { } from "@t3tools/provider-core/server/ProviderAdapter"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index ef05da600de1..5806fc5fb68d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -88,7 +88,7 @@ import { causeErrorTag } from "@t3tools/shared/observability"; import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeSubagentChildThread, diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index a66a6a2b1a5d..f0538385e810 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -35,7 +35,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import { PI_PROVIDER, PiAdapterV2Driver } from "./PiAdapterV2.ts"; +import { PI_PROVIDER, PiAdapterV2Driver } from "@t3tools/provider-pi/server"; export const PI_RPC_REPLAY_PROTOCOL = "pi.rpc-jsonl"; export const PI_REPLAY_ANY = ""; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 6f385a128b39..0335d921da3e 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -23,7 +23,7 @@ import { historyResponseItems, selectHistory, historicalMessage, -} from "./ContextHandoffBudget.ts"; +} from "@t3tools/provider-core/server/handoffBudget"; import { projectContextHandoffForWire } from "./WireProjection.ts"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index f3c4a846b7e7..534217a58a20 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -6,7 +6,11 @@ import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/ import { ContextHandoffBudgetError } from "@t3tools/provider-core/server/failure"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { historyCost, renderHistory, selectHistory } from "./ContextHandoffBudget.ts"; +import { + historyCost, + renderHistory, + selectHistory, +} from "@t3tools/provider-core/server/handoffBudget"; /** * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 0d66db6deb3f..137f617710c5 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -20,7 +20,7 @@ import { historicalMessage, renderHistory, selectHistory, -} from "./ContextHandoffBudget.ts"; +} from "@t3tools/provider-core/server/handoffBudget"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; export class ContextHandoffPrepareError extends Schema.TaggedError()( diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 3e486a14f0de..9f046abba25f 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -67,7 +67,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 226108f6a503..a2e45ed21c51 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -109,7 +109,7 @@ import { } from "./ProjectionStore.ts"; import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; -import { ProviderContinuationRequests } from "./ProviderContinuationRequests.ts"; +import { ProviderContinuationRequests } from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as RunExecutionService from "./RunExecutionService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 2ed8b465b2c3..6e99d80a30e4 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -19,7 +19,7 @@ import * as Semaphore from "effect/Semaphore"; import * as TestClock from "effect/testing/TestClock"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index ac254a0ae27c..928a5c909524 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 9bf834c0efa4..a928680b2402 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -35,7 +35,7 @@ import { contextUsageForHandoff, historicalMessage, latestNativeContextUsage, -} from "./ContextHandoffBudget.ts"; +} from "@t3tools/provider-core/server/handoffBudget"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; import { ProviderAdapterTurnStartError, diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index c9d2b3974713..8a6490ef798f 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -18,7 +18,7 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "./Adapters/OpenCodeAdapterV2.ts"; -import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; +import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; export type BuiltInProviderAdapterDriversV2Env = diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index d12491cc0eb0..5d736f1564be 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -30,7 +30,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 1307b2a77df6..d24ae5b624d9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -35,7 +35,7 @@ import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProjectStore from "../ProjectStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 732e04408c27..a0de3392631c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1,5 +1,5 @@ import { vi } from "vite-plus/test"; -import { historyResponseItems } from "../ContextHandoffBudget.ts"; +import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { assert, describe, it } from "@effect/vitest"; import { CommandId, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ca6c0391491a..143b6522bd46 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -19,7 +19,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; -import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeManualOnlyProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilities, diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index cc08d2b4a10e..bdd5c0449a2c 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -29,7 +29,7 @@ import { } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { defaultProviderContinuationIdentity, @@ -42,7 +42,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { type AcpRegistryAvailableCommands, type AcpRegistryConfigurationProbe, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 817dbff73570..238834f63a11 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -47,7 +47,7 @@ import { removeAntigravitySessionFiles, } from "../acp/AntigravitySessionFiles.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { ProviderDriverError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 55dfe036b98e..ab9e6d2e8f2f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -42,7 +42,7 @@ import { probeClaudeCapabilities, probeClaudeWorkspaceSnapshot, } from "../ClaudeProvider.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelManifest from "../ModelManifest.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { @@ -63,7 +63,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 885a89b65c66..0d6da5a5dea6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -48,7 +48,7 @@ import { withCodexAppServerClient, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "./instanceIdentity.ts"; @@ -64,7 +64,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { codexContinuationIdentity, materializeCodexShadowHome, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index e702548000dc..b7c12ff3391d 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -14,7 +14,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, } from "../CodexProvider.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { type ProviderDriverCreateInput, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index e8bd2eee22e6..21bc7a0b7af8 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -29,7 +29,7 @@ import { checkCursorProviderStatus, } from "../CursorProvider.ts"; import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -42,7 +42,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { probeCursorSkills } from "./CursorSkills.ts"; import { makeCursorAuth } from "../CursorAuth.ts"; import * as CursorCredentialStore from "../CursorCredentialStore.ts"; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index c253401b53e6..5962c7248264 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -22,7 +22,7 @@ import { enrichGrokSnapshot, } from "../GrokProvider.ts"; import { readGrokAccount } from "../grokUsageLimits.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -42,7 +42,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/apps/server/src/provider/Drivers/MuseDriver.ts index 63e164d9db96..e22b22ab0dee 100644 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ b/apps/server/src/provider/Drivers/MuseDriver.ts @@ -14,10 +14,10 @@ import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration. import { ProviderDriverError } from "../Errors.ts"; import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts"; import { makeMuseEnvironment } from "../museSdk.ts"; import { @@ -34,7 +34,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { withInstanceIdentity } from "./instanceIdentity.ts"; const DRIVER_KIND = ProviderDriverKind.make("muse"); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index db4a9caa9e61..76a9dcdc7b78 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -44,7 +44,7 @@ import { openCodeSkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, } from "../OpenCodeProvider.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, @@ -73,7 +73,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 130a63262fc4..6640288c7ff4 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -4,7 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; export type ProviderOrchestrationAdapterInfrastructure = | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index ffaf812e4c84..08a403b70e04 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -28,7 +28,7 @@ import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; -import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; +import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; /** diff --git a/apps/web/package.json b/apps/web/package.json index 53547e6cfc22..ab2ae7b35ea3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cc6e2d10af79..66e170dacf61 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -718,21 +718,6 @@ export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( ); -export const PiAgentIcon: Icon = ({ className, ...props }) => ( - - - - -); - // Official two-color mark from https://forgejo.org/favicon.svg. export const ForgejoIcon: Icon = (props) => ( {label} diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 60d2d346d13b..59b42d0f6751 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -6,11 +6,11 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, - PiSettings, MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this web build ships, in presentation order. */ export const providerClients = makeProviderClientRegistry([ @@ -59,11 +59,7 @@ export const providerClients = makeProviderClientRegistry([ settingsSchema: MuseSettings, badgeLabel: "Beta", }, - { - driverKind: ProviderDriverKind.make("pi"), - label: "Pi", - settingsSchema: PiSettings, - }, + piClient, { driverKind: ProviderDriverKind.make("acpRegistry"), label: "ACP Registry", diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index e0100be873b3..4227182185eb 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -27,7 +27,7 @@ capabilities, never the driver kind. at runtime is a bug. - **Permission modes.** Offer only modes the provider enforces natively, through `supportedRuntimeModes` in the provider presentation ([Grok](../../apps/server/src/provider/GrokProvider.ts) - and [Pi](../../apps/server/src/provider/PiProvider.ts) are examples). Do not imitate a missing + and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The server runs an unoffered stored mode as Supervised ([`RuntimePolicy.ts`](../../apps/server/src/orchestration-v2/RuntimePolicy.ts)). @@ -86,7 +86,7 @@ produce catches them. sending. - **Work the provider starts on its own.** Background commands, subagents, workflows, and goals can finish or start a turn after T3's turn settled. Offer a continuation through - [`ProviderContinuationRequests`](../../apps/server/src/orchestration-v2/ProviderContinuationRequests.ts) + [`ProviderContinuationRequests`](../../packages/provider-core/src/server/continuationRequests.ts) so the parent wakes, and report `hasPendingBackgroundWork` so the session is not released as idle. Dropping it, or killing the session, loses the result. - **Subagents outlive the run that launched them.** A child can report after its parent's turn @@ -103,16 +103,26 @@ produce catches them. ## Where a driver plugs in -- **Contracts:** settings schema, default model, and display name in - [`packages/contracts`](../../packages/contracts/src). Instances store that config in - `providerInstances`. New providers are off by default. -- **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry - in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in - the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy - in [`model-manifest.json`](../../apps/server/src/provider/model-manifest.json) (bump `updatedAt`; - see [model manifest](./model-manifest.md)), and text generation for titles and commit messages. -- **Clients:** web settings metadata and badge, provider icons on web and mobile, and settings - search terms. +A provider lives in its own `packages/provider-` package, with +[Pi](../../packages/provider-pi) as the reference. Drivers not moved yet still live in +[`provider/Drivers`](../../apps/server/src/provider/Drivers). + +- **Package exports.** `./settings` holds the instance settings schema, built with + `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition` + (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe. + `./server` exports the `ProviderDriver` and its adapter driver. Settings live in + `providerInstances`; new providers are off by default. +- **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, + and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, + attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. +- **Server registration:** the driver's entry in + [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in + [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts). + Also its position in the [status order](../../apps/server/src/provider/providerStatusCache.ts), a + compatibility policy in [`model-manifest.json`](../../apps/server/src/provider/model-manifest.json) + (bump `updatedAt`; see [model manifest](./model-manifest.md)), and its default model in contracts. +- **Client registration:** the client definition in the web and mobile provider registries, and + settings search terms. Clients draw the package icon; do not add a per-driver icon branch. - **Docs:** a `docs/user/providers-.md` guide in the product's voice, a row in the [install](../user/install.md#providers) table, and any provider difference that changes the [permission modes](../user/permission-modes.md) page. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 3da603d9ce01..b8542cbbef38 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -34,7 +34,7 @@ trust discovery. T3 injects only its namespaced MCP bridge, so a Pi session beha the Pi TUI. Pi session files back native resume, rollback, and same-instance thread forks. Forks use Pi's CLI in the destination directory because RPC session switching retains the source session's cwd. Provider switches still use portable handoff summaries. -See the [adapter](../../apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts). +See the [adapter](../../packages/provider-pi/src/server/adapter.ts). Antigravity separates account profiles per instance while sharing installed executables across the environment. It forces file-based credential storage because the native macOS keychain entry would diff --git a/package.json b/package.json index 078da2b29452..eece70e89ef5 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cce40c70ce2..c9b3c00136dc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -525,7 +525,8 @@ export const UsageModelPriceOverride = Schema.Struct({ }); export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; -const makeBinaryPathSetting = (fallback: string) => +/** A binary path setting that decodes empty input to the provider's default executable. */ +export const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( Schema.String, @@ -571,7 +572,8 @@ export type ProviderSettingsOrder = readonl string >[]; -function makeProviderSettingsSchema( +/** A provider settings struct whose fields the settings form renders in `order`. */ +export function makeProviderSettingsSchema( fields: Fields, options?: { readonly order?: ProviderSettingsOrder | undefined; @@ -861,39 +863,6 @@ export const MuseSettings = makeProviderSettingsSchema( ); export type MuseSettings = typeof MuseSettings.Type; -export const PiSettings = makeProviderSettingsSchema( - { - // Off by default like Cursor and Grok. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("pi").pipe( - Schema.annotateKey({ - title: "Binary path", - description: "Path to the Pi coding agent binary.", - providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, - }), - ), - launchArgs: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Launch arguments", - description: "Additional CLI arguments passed to pi --mode rpc on session start.", - providerSettingsForm: { clearWhenEmpty: "omit" }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["binaryPath", "launchArgs"], - }, -); -export type PiSettings = typeof PiSettings.Type; - export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index 23ac2a66e846..073faf381440 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -19,6 +19,10 @@ "types": "./src/server/collectStreamText.ts", "import": "./src/server/collectStreamText.ts" }, + "./server/continuationRequests": { + "types": "./src/server/continuationRequests.ts", + "import": "./src/server/continuationRequests.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" @@ -31,6 +35,10 @@ "types": "./src/server/failure.ts", "import": "./src/server/failure.ts" }, + "./server/handoffBudget": { + "types": "./src/server/handoffBudget.ts", + "import": "./src/server/handoffBudget.ts" + }, "./server/IdAllocator": { "types": "./src/server/IdAllocator.ts", "import": "./src/server/IdAllocator.ts" @@ -47,6 +55,10 @@ "types": "./src/server/maintenanceResolver.ts", "import": "./src/server/maintenanceResolver.ts" }, + "./server/managedProvider": { + "types": "./src/server/managedProvider.ts", + "import": "./src/server/managedProvider.ts" + }, "./server/mcpSession": { "types": "./src/server/mcpSession.ts", "import": "./src/server/mcpSession.ts" @@ -91,6 +103,10 @@ "types": "./src/server/snapshotProbe.ts", "import": "./src/server/snapshotProbe.ts" }, + "./server/snapshotSettings": { + "types": "./src/server/snapshotSettings.ts", + "import": "./src/server/snapshotSettings.ts" + }, "./server/textGeneration": { "types": "./src/server/textGeneration.ts", "import": "./src/server/textGeneration.ts" diff --git a/packages/provider-core/src/client.ts b/packages/provider-core/src/client.ts index 18c64cede595..74a6e5ac8a7a 100644 --- a/packages/provider-core/src/client.ts +++ b/packages/provider-core/src/client.ts @@ -31,9 +31,26 @@ export interface ProviderEnvironmentField { readonly sensitive?: boolean; } +/** + * A provider glyph as plain data, so web and mobile can each draw it with + * their own SVG renderer. Colors are per theme; a path without a fill uses + * the icon's `fill`. + */ +export interface ProviderIcon { + readonly viewBox: string; + readonly fill: { readonly light: string; readonly dark: string }; + readonly paths: ReadonlyArray<{ + readonly d: string; + readonly fillRule?: "evenodd" | "nonzero"; + readonly fill?: { readonly light: string; readonly dark: string }; + }>; +} + export interface ProviderClientDefinition { readonly driverKind: ProviderDriverKind; readonly label: string; + /** Omitted for drivers whose glyph a client still draws itself. */ + readonly icon?: ProviderIcon; readonly settingsSchema: ProviderSettingsSchema; readonly environmentFields?: ReadonlyArray; /** Whether the driver has a default instance backed by legacy `providers.` settings. */ diff --git a/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts b/packages/provider-core/src/server/continuationRequests.ts similarity index 97% rename from apps/server/src/orchestration-v2/ProviderContinuationRequests.ts rename to packages/provider-core/src/server/continuationRequests.ts index e6af7fa791b5..f7c8606ace14 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationRequests.ts +++ b/packages/provider-core/src/server/continuationRequests.ts @@ -59,7 +59,7 @@ export interface ProviderContinuationRequest { export class ProviderContinuationRequests extends Context.Reference<{ readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; readonly take: Effect.Effect; -}>("t3/orchestration-v2/ProviderContinuationRequests", { +}>("@t3tools/provider-core/server/continuationRequests/ProviderContinuationRequests", { defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }), }) {} diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts similarity index 100% rename from apps/server/src/orchestration-v2/ContextHandoffBudget.ts rename to packages/provider-core/src/server/handoffBudget.ts diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts similarity index 88% rename from apps/server/src/provider/makeManagedServerProvider.test.ts rename to packages/provider-core/src/server/managedProvider.test.ts index 767ed192b25d..f46eb897bbb6 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -1,4 +1,3 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, @@ -7,7 +6,6 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -18,14 +16,10 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../config.ts"; -import * as ServerSettings from "../serverSettings.ts"; -import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; -import * as ProviderHostLive from "./ProviderHostLive.ts"; +import { ProviderHost, type ProviderHostShape } from "./ProviderHost.ts"; +import { makeManagedServerProvider } from "./managedProvider.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); -const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const fastModeCapabilities = createModelCapabilities({ optionDescriptors: [ { @@ -102,60 +96,33 @@ const refreshedSnapshotSecond: ServerProvider = { message: "Refreshed provider availability again.", }; -function layerBackgroundPolicy(shouldRunScopeWork: boolean) { - return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - reportClientActivity: () => Effect.void, - removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, - snapshot: Effect.succeed({ - hostPower: { - source: "unknown", - idle: "unknown", - idleSeconds: null, - locked: "unknown", - suspended: false, - onBattery: "unknown", - lowPowerMode: "unknown", - thermalState: "unknown", - stale: true, - updatedAt: TEST_EPOCH, +/** A host whose settings never change and whose background demand is fixed. */ +function layerProviderHost(input: { + readonly runBackgroundWork: boolean; + readonly settings?: Pick["settings"]; +}) { + return Layer.succeed( + ProviderHost, + ProviderHost.of({ + paths: { + cwd: process.cwd(), + baseDir: "/t3", + stateDir: "/t3/userdata", + providerStatusCacheDir: "/t3/caches", }, - leases: [], - activeForegroundLeaseCount: 0, - activeScopeKeys: [], - shouldRunOpportunisticWork: true, - updatedAt: TEST_EPOCH, + settings: input.settings ?? { + get: Effect.succeed(DEFAULT_SERVER_SETTINGS), + changes: Stream.empty, + subscribe: Effect.succeed(Stream.empty), + }, + shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), + resolveAttachmentPath: () => null, }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(shouldRunScopeWork), - shouldRunScopeWork: () => Effect.succeed(shouldRunScopeWork), - shouldRunOpportunisticWork: Effect.succeed(shouldRunScopeWork), - }); -} - -const layerBackgroundPolicyAlwaysRun = layerBackgroundPolicy(true); -const layerBackgroundPolicyNeverRun = layerBackgroundPolicy(false); -const layerServerSettingsTest = ServerSettings.layerTest(); -const layerServerConfigTest = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-managed-provider-test-", -}).pipe(Layer.provide(NodeServices.layer)); - -function layerProviderHost( - layerBackground: Layer.Layer, - layerSettings: Layer.Layer, -) { - return ProviderHostLive.layer.pipe( - Layer.provideMerge(layerBackground), - Layer.provideMerge(layerSettings), - Layer.provide(layerServerConfigTest), ); } -const layerAlwaysRunTest = layerProviderHost( - layerBackgroundPolicyAlwaysRun, - layerServerSettingsTest, -); -const layerNeverRunTest = layerProviderHost(layerBackgroundPolicyNeverRun, layerServerSettingsTest); +const layerAlwaysRunTest = layerProviderHost({ runBackgroundWork: true }); +const layerNeverRunTest = layerProviderHost({ runBackgroundWork: false }); const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, @@ -314,21 +281,13 @@ describe("makeManagedServerProvider", () => { }; const serverSettingsRef = yield* Ref.make(initialServerSettings); const serverSettingsChanges = yield* PubSub.unbounded(); - const layerServerSettings = Layer.succeed( - ServerSettings.ServerSettingsService, - ServerSettings.ServerSettingsService.of({ - start: Effect.void, - ready: Effect.void, - getSettings: Ref.get(serverSettingsRef), - updateSettings: () => Effect.die(new Error("unused in this test")), - updateProviderInstance: () => Effect.die(new Error("unused in this test")), - withSettingsSnapshot: (use) => Ref.get(serverSettingsRef).pipe(Effect.flatMap(use)), - streamChanges: Stream.empty, - subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( - Effect.map((subscription) => Stream.fromSubscription(subscription)), - ), - }), - ); + const hostSettings: ProviderHostShape["settings"] = { + get: Ref.get(serverSettingsRef), + changes: Stream.empty, + subscribe: PubSub.subscribe(serverSettingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }; const checkCalls = yield* Ref.make(0); const initialCheckDone = yield* Deferred.make(); const periodicCheckDone = yield* Deferred.make(); @@ -348,7 +307,7 @@ describe("makeManagedServerProvider", () => { Effect.as(refreshedSnapshot), ), }).pipe( - Effect.provide(layerProviderHost(layerBackgroundPolicyAlwaysRun, layerServerSettings)), + Effect.provide(layerProviderHost({ runBackgroundWork: true, settings: hostSettings })), ); yield* Deferred.await(initialCheckDone); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/packages/provider-core/src/server/managedProvider.ts similarity index 97% rename from apps/server/src/provider/makeManagedServerProvider.ts rename to packages/provider-core/src/server/managedProvider.ts index e2637cc1a146..a04873f0e608 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -15,12 +15,9 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import { - applyUsageLimitsUpdate, - resolveUsageLimitsAfterProbe, -} from "@t3tools/provider-core/server/usageLimits"; -import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; +import { ProviderHost } from "./ProviderHost.ts"; +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; +import type { ServerProviderShape } from "./snapshot.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts similarity index 94% rename from apps/server/src/provider/providerUpdateSettings.ts rename to packages/provider-core/src/server/snapshotSettings.ts index f61061439730..87286728fe60 100644 --- a/apps/server/src/provider/providerUpdateSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Stream from "effect/Stream"; -import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; +import type { ProviderHostShape } from "./ProviderHost.ts"; export interface ProviderSnapshotSettings { readonly provider: Settings; diff --git a/packages/provider-pi/package.json b/packages/provider-pi/package.json new file mode 100644 index 000000000000..7387a33303f6 --- /dev/null +++ b/packages/provider-pi/package.json @@ -0,0 +1,36 @@ +{ + "name": "@t3tools/provider-pi", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-pi/src/client.ts b/packages/provider-pi/src/client.ts new file mode 100644 index 000000000000..2b1acd8f2f88 --- /dev/null +++ b/packages/provider-pi/src/client.ts @@ -0,0 +1,27 @@ +/** + * Pi's client definition: settings form, label, and glyph. Browser- and + * React Native-safe. + * + * @module provider-pi/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { PiSettings } from "./settings.ts"; + +export const piClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("pi"), + label: "Pi", + settingsSchema: PiSettings, + icon: { + viewBox: "165.29 165.29 469.43 469.43", + fill: { light: "#0F0F0F", dark: "#F5F5F5" }, + paths: [ + { + d: "M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z", + fillRule: "evenodd", + }, + { d: "M517.36 400H634.72V634.72H517.36Z" }, + ], + }, +}); diff --git a/packages/provider-pi/src/server.ts b/packages/provider-pi/src/server.ts new file mode 100644 index 000000000000..8205371dbbe2 --- /dev/null +++ b/packages/provider-pi/src/server.ts @@ -0,0 +1,8 @@ +/** + * Pi's server entry: the driver the server registers, plus the adapter + * driver used by replay tests and the fixture recorder. + * + * @module provider-pi/server + */ +export { PiDriver, type PiDriverEnv } from "./server/driver.ts"; +export { PI_PROVIDER, PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/packages/provider-pi/src/server/adapter.test.ts similarity index 99% rename from apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts rename to packages/provider-pi/src/server/adapter.test.ts index 8789b5268498..65a01a403095 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -35,7 +35,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { @@ -43,14 +43,14 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, } from "@t3tools/provider-core/server/ProviderAdapter"; -import { handoffBudget } from "../ContextHandoffBudget.ts"; +import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import { makePiAdapterV2, PiAdapterV2Driver, PI_PROVIDER, type PiAdapterV2Options, -} from "./PiAdapterV2.ts"; -import { makePiRpcConnection, type PiRpcRecord } from "./PiRpc.ts"; +} from "./adapter.ts"; +import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; const layerTest = Layer.mergeAll( NodeServices.layer, diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/packages/provider-pi/src/server/adapter.ts similarity index 99% rename from apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts rename to packages/provider-pi/src/server/adapter.ts index 520992dbc4e4..73e1f64953c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -27,7 +27,6 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { defaultInstanceIdForDriver, - PiSettings, ProviderDriverKind, type ChatAttachment, type ModelSelection, @@ -46,6 +45,7 @@ import { type ProviderInstanceId, type OrchestrationV2ProviderTurnTokenUsage, } from "@t3tools/contracts"; +import { PiSettings } from "../settings.ts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -68,11 +68,11 @@ import { parsePiCompactCommand, parsePiDiscoveredCommands, type PiCompactCommand, -} from "../../provider/PiCommands.ts"; +} from "./commands.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -91,13 +91,13 @@ import { piRecordString as recordString, type PiRpcConnection, type PiRpcRecord, -} from "./PiRpc.ts"; +} from "./rpc.ts"; import { buildPiRpcLaunch, materializePiT3McpExtension, resolvePiLaunchArgs, -} from "./piT3McpInjection.ts"; -import { PI_FILE_CHANGE_TOOLS } from "./piT3McpExtensionSource.ts"; +} from "./mcpInjection.ts"; +import { PI_FILE_CHANGE_TOOLS } from "./mcpExtensionSource.ts"; export const PI_PROVIDER = ProviderDriverKind.make("pi"); const PI_DRIVER_KIND = PI_PROVIDER; diff --git a/apps/server/src/provider/PiCommands.test.ts b/packages/provider-pi/src/server/commands.test.ts similarity index 99% rename from apps/server/src/provider/PiCommands.test.ts rename to packages/provider-pi/src/server/commands.test.ts index b84c181e9fc5..09f39130dd06 100644 --- a/apps/server/src/provider/PiCommands.test.ts +++ b/packages/provider-pi/src/server/commands.test.ts @@ -6,7 +6,7 @@ import { parsePiDiscoveredCommands, PI_COMPACT_SLASH_COMMAND, withPiBuiltinSlashCommands, -} from "./PiCommands.ts"; +} from "./commands.ts"; it("maps current Pi skill metadata to T3's user and project skill scopes", () => { expect( diff --git a/apps/server/src/provider/PiCommands.ts b/packages/provider-pi/src/server/commands.ts similarity index 100% rename from apps/server/src/provider/PiCommands.ts rename to packages/provider-pi/src/server/commands.ts diff --git a/apps/server/src/provider/Drivers/PiDriver.test.ts b/packages/provider-pi/src/server/driver.test.ts similarity index 92% rename from apps/server/src/provider/Drivers/PiDriver.test.ts rename to packages/provider-pi/src/server/driver.test.ts index 2c4751ddf812..83a6f33eb57c 100644 --- a/apps/server/src/provider/Drivers/PiDriver.test.ts +++ b/packages/provider-pi/src/server/driver.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { ProviderInstanceId, type PiSettings } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -13,28 +13,24 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderHostLive from "../ProviderHostLive.ts"; -import { PiDriver } from "./PiDriver.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type { PiSettings } from "../settings.ts"; +import { PiDriver } from "./driver.ts"; -const layerDeps = Layer.mergeAll( - ServerConfig.layerTest("/machine", { prefix: "t3-pi-driver-" }), - IdAllocator.layer, - ServerSettings.layerTest({ enableProviderUpdateChecks: false }), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), +const layerTest = Layer.mergeAll( + layerTestProviderHost({ + cwd: "/machine", + settings: { ...DEFAULT_SERVER_SETTINGS, enableProviderUpdateChecks: false }, + runBackgroundWork: false, }), + IdAllocator.layer, Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("Unexpected HTTP")), ), ).pipe(Layer.provideMerge(NodeServices.layer)); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); - const decodeRequest = Schema.decodeSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/packages/provider-pi/src/server/driver.ts similarity index 93% rename from apps/server/src/provider/Drivers/PiDriver.ts rename to packages/provider-pi/src/server/driver.ts index 6bfc0ac15421..eb58a6dcc614 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -6,7 +6,8 @@ * Pi state (sessions, settings, extensions, auth) lives in the user's own * `~/.pi/agent`, so continuation identity uses the default instance grouping. */ -import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { PiSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -15,19 +16,16 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; -import { - PiAdapterV2Driver, - type PiAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/PiAdapterV2.ts"; -import { ProviderDriverError } from "../Errors.ts"; +import { makePiTextGeneration } from "./textGeneration.ts"; +import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialPiProviderSnapshot, checkPiProviderStatus, discoverPiCommandsForCwd, enrichPiSnapshot, -} from "../PiProvider.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +} from "./status.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -44,7 +42,7 @@ import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; const decodePiSettings = Schema.decodeSync(PiSettings); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts similarity index 99% rename from apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts rename to packages/provider-pi/src/server/mcpExtensionSource.test.ts index 5498d2afec83..4d4aef33d8fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -2,7 +2,7 @@ import * as NodeModule from "node:module"; import * as NodeVM from "node:vm"; import { assert, describe, it } from "@effect/vitest"; -import { PI_T3_MCP_EXTENSION_SOURCE } from "./piT3McpExtensionSource.ts"; +import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; type RequestHook = ( event: { payload: unknown }, diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts similarity index 100% rename from apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts rename to packages/provider-pi/src/server/mcpExtensionSource.ts diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts rename to packages/provider-pi/src/server/mcpInjection.test.ts index b462b98a612a..45f8781a5635 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -9,12 +9,12 @@ import { T3_MCP_BEARER_ENV, T3_MCP_URL_ENV, T3_PI_RUNTIME_MODE_ENV, -} from "./piT3McpExtensionSource.ts"; +} from "./mcpExtensionSource.ts"; import { buildPiRpcLaunch, materializePiT3McpExtension, resolvePiLaunchArgs, -} from "./piT3McpInjection.ts"; +} from "./mcpInjection.ts"; const threadId = ThreadId.make("thread-pi-t3-mcp"); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts similarity index 99% rename from apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts rename to packages/provider-pi/src/server/mcpInjection.ts index 5f3777821780..25e1d3ddbb98 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -9,7 +9,7 @@ import { T3_MCP_BEARER_ENV, T3_MCP_URL_ENV, T3_PI_RUNTIME_MODE_ENV, -} from "./piT3McpExtensionSource.ts"; +} from "./mcpExtensionSource.ts"; const RESERVED_PI_LAUNCH_ARGUMENTS = new Set([ "--continue", diff --git a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts b/packages/provider-pi/src/server/rpc.ts similarity index 100% rename from apps/server/src/orchestration-v2/Adapters/PiRpc.ts rename to packages/provider-pi/src/server/rpc.ts diff --git a/apps/server/src/provider/PiProvider.test.ts b/packages/provider-pi/src/server/status.test.ts similarity index 97% rename from apps/server/src/provider/PiProvider.test.ts rename to packages/provider-pi/src/server/status.test.ts index 7ca54dbf3cb0..133a505612b1 100644 --- a/apps/server/src/provider/PiProvider.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -5,7 +5,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; +import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./status.ts"; const encoder = new TextEncoder(); diff --git a/apps/server/src/provider/PiProvider.ts b/packages/provider-pi/src/server/status.ts similarity index 98% rename from apps/server/src/provider/PiProvider.ts rename to packages/provider-pi/src/server/status.ts index 4ea63f02ea49..54be47d3dfd9 100644 --- a/apps/server/src/provider/PiProvider.ts +++ b/packages/provider-pi/src/server/status.ts @@ -10,10 +10,10 @@ */ import { type CustomModelSetting, - type PiSettings, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; +import type { PiSettings } from "../settings.ts"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -26,17 +26,14 @@ import * as Result from "effect/Result"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { - buildPiRpcLaunch, - resolvePiLaunchArgs, -} from "../orchestration-v2/Adapters/piT3McpInjection.ts"; +import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; import { makePiRpcConnection, PiRpcError, PiRpcTimeoutError, piRecordField as recordField, piRecordString as recordString, -} from "../orchestration-v2/Adapters/PiRpc.ts"; +} from "./rpc.ts"; import { buildServerProvider, isCommandMissingCause, @@ -52,12 +49,12 @@ import { import { EMPTY_PI_MODEL_CAPABILITIES, thinkingCapabilitiesForPiModel, -} from "./piThinkingCapabilities.ts"; +} from "./thinkingCapabilities.ts"; import { parsePiDiscoveredCommands, withPiBuiltinSlashCommands, type PiDiscoveredCommands, -} from "./PiCommands.ts"; +} from "./commands.ts"; const PI_PRESENTATION = { displayName: "Pi", diff --git a/apps/server/src/textGeneration/PiTextGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts similarity index 98% rename from apps/server/src/textGeneration/PiTextGeneration.test.ts rename to packages/provider-pi/src/server/textGeneration.test.ts index 336e110a9ffc..fb56753785a8 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -9,7 +9,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; -import { makePiTextGeneration } from "./PiTextGeneration.ts"; +import { makePiTextGeneration } from "./textGeneration.ts"; const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts similarity index 93% rename from apps/server/src/textGeneration/PiTextGeneration.ts rename to packages/provider-pi/src/server/textGeneration.ts index 7d52e4e08e65..cf334556fdf9 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -10,13 +10,11 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { TextGenerationError, type PiSettings } from "@t3tools/contracts"; +import { TextGenerationError } from "@t3tools/contracts"; +import type { PiSettings } from "../settings.ts"; -import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; -import { - buildPiRpcLaunch, - resolvePiLaunchArgs, -} from "../orchestration-v2/Adapters/piT3McpInjection.ts"; +import { makePiRpcConnection, parsePiModelSlug } from "./rpc.ts"; +import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const PI_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/provider/piThinkingCapabilities.test.ts b/packages/provider-pi/src/server/thinkingCapabilities.test.ts similarity index 95% rename from apps/server/src/provider/piThinkingCapabilities.test.ts rename to packages/provider-pi/src/server/thinkingCapabilities.test.ts index b6474bf2d9ec..5617c01b3943 100644 --- a/apps/server/src/provider/piThinkingCapabilities.test.ts +++ b/packages/provider-pi/src/server/thinkingCapabilities.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; +import { thinkingCapabilitiesForPiModel } from "./thinkingCapabilities.ts"; describe("thinkingCapabilitiesForPiModel", () => { it("gives every choice its real level id and tags Pi's default", () => { diff --git a/apps/server/src/provider/piThinkingCapabilities.ts b/packages/provider-pi/src/server/thinkingCapabilities.ts similarity index 100% rename from apps/server/src/provider/piThinkingCapabilities.ts rename to packages/provider-pi/src/server/thinkingCapabilities.ts diff --git a/packages/provider-pi/src/settings.ts b/packages/provider-pi/src/settings.ts new file mode 100644 index 000000000000..8d19e29ac6a2 --- /dev/null +++ b/packages/provider-pi/src/settings.ts @@ -0,0 +1,47 @@ +/** + * Pi instance settings. Shared by the server driver and the client settings + * form, so it holds only browser-safe schema code. + * + * @module provider-pi/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const PiSettings = makeProviderSettingsSchema( + { + // Off by default like Cursor and Grok. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("pi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Pi coding agent binary.", + providerSettingsForm: { placeholder: "pi", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to pi --mode rpc on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type PiSettings = typeof PiSettings.Type; diff --git a/packages/provider-pi/tsconfig.json b/packages/provider-pi/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-pi/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3da3784edd8..b948baba7c65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,12 @@ importers: '@t3tools/mobile-terminal-native': specifier: file:./modules/t3-terminal version: file:apps/mobile/modules/t3-terminal + '@t3tools/provider-core': + specifier: workspace:* + version: link:../../packages/provider-core + '@t3tools/provider-pi': + specifier: workspace:* + version: link:../../packages/provider-pi '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -597,6 +603,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-pi': + specifier: workspace:* + version: link:../../packages/provider-pi diff: specifier: 8.0.3 version: 8.0.3 @@ -724,6 +733,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-pi': + specifier: workspace:* + version: link:../../packages/provider-pi '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -1087,6 +1099,37 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-pi: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-testing: dependencies: '@effect/platform-node': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 296b3ac1441f..8fb9ab883995 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -30,6 +30,7 @@ const workspaceFiles = [ "packages/tailscale/package.json", "packages/provider-core/package.json", "packages/provider-testing/package.json", + "packages/provider-pi/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", From 7b57b723d3b7490b50fffbd99fe89c6aa98fae1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 14:38:35 -0700 Subject: [PATCH 026/377] feat(models): tell users when a CLI update unlocks a new model (#17307) Co-authored-by: Claude Opus 5.5 --- .../features/threads/ThreadSettingsSheet.tsx | 24 +++++- apps/mobile/src/lib/modelOptions.ts | 27 ++++++- .../src/provider/ClaudeModelCatalog.test.ts | 5 ++ .../server/src/provider/ClaudeModelCatalog.ts | 26 +++++++ apps/server/src/provider/ClaudeProvider.ts | 4 + .../server/src/provider/ModelManifest.test.ts | 74 +++++++++++++++++++ apps/server/src/provider/ModelManifest.ts | 44 ++++++++++- .../src/provider/ProviderRegistry.test.ts | 34 +++++++++ apps/server/src/provider/ProviderRegistry.ts | 8 +- apps/server/src/provider/model-manifest.json | 20 ++++- .../components/chat/ModelPickerContent.tsx | 34 +++++++++ docs/internals/model-manifest.md | 6 ++ packages/client-runtime/package.json | 4 + .../src/providerUpdateRequiredModels.test.ts | 47 ++++++++++++ .../src/providerUpdateRequiredModels.ts | 45 +++++++++++ packages/contracts/src/server.ts | 15 ++++ .../provider-core/src/server/snapshotProbe.ts | 5 ++ 17 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 packages/client-runtime/src/providerUpdateRequiredModels.test.ts create mode 100644 packages/client-runtime/src/providerUpdateRequiredModels.ts diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 1ff108198c13..88e411328d66 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -90,6 +90,7 @@ import { providerSectionIsCollapsed, toggleModelFavorite, } from "./thread-settings-sheet-state"; +import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; /** * Everyday harnesses start expanded; every other provider (OpenRouter catalogs @@ -565,6 +566,11 @@ type ThreadSettingsCatalogItem = readonly isFirst: boolean; readonly isLast: boolean; } + | { + readonly kind: "notice"; + readonly key: string; + readonly text: string; + } | { readonly kind: "empty"; readonly key: "empty"; @@ -657,7 +663,12 @@ function useThreadSettingsCatalogItems( ), session.favoriteKeys, ); - if (visibleModels.length === 0) { + // Favorites list only selectable models, so it never explains gated ones. + const updateRequiredNotice = + group.updateRequired && session.providerFilter !== FAVORITES_PROVIDER_FILTER + ? formatProviderUpdateRequiredNotice(group.updateRequired, session.searchQuery) + : null; + if (visibleModels.length === 0 && !updateRequiredNotice) { return []; } const isPrimary = driver !== undefined && PRIMARY_PROVIDER_DRIVERS.has(driver); @@ -695,6 +706,15 @@ function useThreadSettingsCatalogItems( isFirst: index === 0, isLast: index === provider.models.length - 1, })), + ...(!collapsed && updateRequiredNotice + ? [ + { + kind: "notice" as const, + key: `notice:${group.providerKey}`, + text: updateRequiredNotice, + }, + ] + : []), ]; }), [ @@ -853,6 +873,8 @@ function ThreadSettingsMainContent(props: { option={item.option} /> ); + } else if (item.kind === "notice") { + content = {item.text}; } else if (item.kind === "empty") { content = ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4469becee1a2..436d6b9996fb 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -24,12 +24,20 @@ export type ModelOption = { readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; + readonly providerUpdateRequired?: ProviderUpdateRequired; }; +type ProviderUpdateRequired = Pick< + T3ServerConfig["providers"][number], + "driver" | "updateRequiredModels" +>; + export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; readonly models: ReadonlyArray; + /** The provider fields that name announced models its CLI is too old to run. */ + readonly updateRequired?: ProviderUpdateRequired; }; function providerDisplayLabel(provider: { @@ -174,6 +182,9 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); + const updateRequired = provider.updateRequiredModels?.length + ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } + : undefined; for (const model of provider.models) { const key = `${provider.instanceId}:${model.slug}`; options.set(key, { @@ -189,6 +200,7 @@ export function buildModelOptions( ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, + ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -239,7 +251,8 @@ export function buildModelOptions( providerDriver, isDefault: false, isLegacy: model?.isLegacy === true, - ...(isModelSelectionUnavailable(config, fallbackModelSelection) + ...(isModelSelectionUnavailable(config, fallbackModelSelection) || + provider?.updateRequiredModels?.some((gated) => gated.slug === fallbackModelSelection.model) ? { isUnavailable: true } : {}), capabilities: model?.capabilities ?? null, @@ -252,15 +265,24 @@ export function buildModelOptions( } export function groupByProvider(options: ReadonlyArray): ReadonlyArray { - const groups = new Map(); + const groups = new Map< + string, + { + providerLabel: string; + models: ModelOption[]; + updateRequired: ProviderUpdateRequired | undefined; + } + >(); for (const option of options) { const existing = groups.get(option.providerKey); if (existing) { existing.models.push(option); + existing.updateRequired ??= option.providerUpdateRequired; } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, models: [option], + updateRequired: option.providerUpdateRequired, }); } } @@ -269,6 +291,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerKey, providerLabel: group.providerLabel, models: group.models, + ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}), })); } diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index c0384e102b8a..5f44c317f0e6 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -12,6 +12,7 @@ import { resolveClaudeModelCatalog, resolveClaudeModelsForVersion, resolveClaudeModelSlug, + resolveClaudeUpdateRequiredModels, scopeClaudeModelCatalog, } from "./ClaudeModelCatalog.ts"; @@ -126,6 +127,10 @@ describe("Claude model catalog", () => { resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), ["claude-synthetic-next"], ); + assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.1.9"), [ + { slug: "claude-synthetic-next", name: "Claude Synthetic Next", minVersion: "3.2.0" }, + ]); + assert.deepStrictEqual(resolveClaudeUpdateRequiredModels(catalog, "3.2.0"), []); assert.strictEqual( formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index e601e9dc9392..14d321d792b3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -4,6 +4,7 @@ import { type ModelSelection, ProviderDriverKind, type ServerProviderModel, + type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { @@ -167,6 +168,31 @@ export function resolveClaudeModelsForVersion( .map((entry) => entry.model); } +/** + * Current catalog models the installed Claude Code is too old to run, so the + * picker can show them as "update to use" instead of hiding them. + */ +export function resolveClaudeUpdateRequiredModels( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + if (!version) return []; + return catalog.models.flatMap(({ model, compatibility }) => { + const minVersion = compatibility.minVersion; + if (model.isLegacy || !minVersion || compareSemverVersions(version, minVersion) >= 0) { + return []; + } + return [ + { + slug: model.slug, + name: model.name, + ...(model.badge ? { badge: model.badge } : {}), + minVersion, + }, + ]; + }); +} + export function formatClaudeVersionUpgradeMessage( catalog: ClaudeModelCatalog, version: string | null, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 0770a50f873a..01d731481c23 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -49,6 +49,7 @@ import { type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, + resolveClaudeUpdateRequiredModels, } from "./ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -560,6 +561,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); + const updateRequiredModels = resolveClaudeUpdateRequiredModels(modelCatalog, parsedVersion); const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities @@ -575,6 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { @@ -612,6 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models, + updateRequiredModels, slashCommands: dedupedSlashCommands, skills, probe: { diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 8b8164387f26..666281a8ac5b 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -152,6 +152,80 @@ describe("applyManifestDefault", () => { }); }); +describe("applyModelManifest", () => { + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + codex: { + profiles: {}, + models: [ + { + slug: "gpt-next", + name: "GPT Next", + status: "current", + badge: "new", + adapter: { codex: { minVersion: "1.2.0" } }, + }, + { slug: "gpt-unversioned", name: "GPT Unversioned", status: "current" }, + { + slug: "gpt-retired", + name: "GPT Retired", + status: "legacy", + adapter: { codex: { minVersion: "1.2.0" } }, + }, + ], + }, + }, + }; + const draft = (version: string | null, models: ReadonlyArray = []) => ({ + enabled: true, + installed: true, + version, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models, + slashCommands: [], + skills: [], + }); + + it("names current Codex models that need a newer CLI and are missing from discovery", () => { + assert.deepStrictEqual( + ModelManifest.applyModelManifest(draft("1.1.9"), manifest, CODEX).updateRequiredModels, + [{ slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }], + ); + for (const result of [ + // The CLI is new enough. + ModelManifest.applyModelManifest(draft("1.2.0"), manifest, CODEX), + // The CLI already lists the model, even under a qualified slug. + ModelManifest.applyModelManifest( + draft("1.1.9", [model({ slug: "openai.gpt-next" })]), + manifest, + CODEX, + ), + // An unknown version cannot be compared. + ModelManifest.applyModelManifest(draft(null), manifest, CODEX), + // A qualified manifest slug still matches the discovered family. + ModelManifest.applyModelManifest( + draft("1.1.9", [model({ slug: "gpt-next" })]), + { + ...manifest, + providers: { + codex: { + profiles: {}, + models: [{ ...manifest.providers!.codex!.models[0]!, slug: "openai.gpt-next" }], + }, + }, + }, + CODEX, + ), + ]) { + assert.isUndefined(result.updateRequiredModels); + } + }); +}); + describe("resolveProviderCatalog", () => { it("resolves generic model presentation through a reusable profile", () => { const manifest: ModelManifest.ModelManifestData = { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 402102c93425..2f0f65cf5390 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -18,13 +18,16 @@ import { TrimmedNonEmptyString, type ProviderDriverKind, type ServerProviderModel, + type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; import { codexModelFamily } from "@t3tools/shared/model"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -233,16 +236,55 @@ export function applyModelManifest( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ServerProviderDraft { + const { updateRequiredModels: _previous, ...rest } = draft; + const updateRequiredModels = + driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : []; return { - ...draft, + ...rest, models: applyManifestDefault( classifyModels(draft.models, manifest, driverKind), manifest, driverKind, ), + ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), }; } +const CodexModelAdapter = Schema.Struct({ + codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), +}); +const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); + +/** + * Codex lists only the models its own build knows, so a model released after + * the installed CLI never shows up. A current manifest entry with + * `adapter.codex.minVersion` names that model, letting the picker say an update + * unlocks it instead of leaving users to wonder where it is. + */ +function codexUpdateRequiredModels( + manifest: ModelManifestData, + draft: ServerProviderDraft, +): ReadonlyArray { + const version = draft.version?.replace(/^v/, ""); + if (!version || parseSemver(version) === null) return []; + const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); + return (manifest.providers?.codex?.models ?? []).flatMap((entry) => { + if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; + const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex + ?.minVersion; + if (!minVersion || parseSemver(minVersion) === null) return []; + if (compareSemverVersions(version, minVersion) >= 0) return []; + return [ + { + slug: entry.slug, + name: entry.name, + ...(entry.badge ? { badge: entry.badge } : {}), + minVersion, + }, + ]; + }); +} + /** The manifest's chat default for `driverKind`, when it names one. */ export function manifestDefaultModel( manifest: ModelManifestData, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 11cab0e1d011..733bb59280cf 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -782,6 +782,40 @@ it.layer( ); }); + it("does not bring back models the installed CLI is too old to run", () => { + // The pending snapshot lists the whole catalog before the version is known. + const pendingProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: null, + models: [ + { slug: "claude-old", name: "Old", isCustom: false, capabilities: null }, + { slug: "claude-next", name: "Next", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const probedProvider = { + ...pendingProvider, + status: "ready", + installed: true, + auth: { status: "authenticated" }, + version: "1.0.0", + models: [pendingProvider.models[0]], + updateRequiredModels: [{ slug: "claude-next", name: "Next", minVersion: "1.1.0" }], + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(pendingProvider, probedProvider).models, + [...probedProvider.models], + ); + }); + it("drops stale ACP Registry models missing from a completed discovery probe", () => { const previousProvider = { instanceId: ProviderInstanceId.make("acpRegistry_codex"), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a76ad34c8333..5f5120ee28bc 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -260,7 +260,13 @@ const mergeProviderModels = ( // Custom rows are derived from settings and every snapshot carries the full // current list, so a custom model missing from `nextModels` was removed by // the user and must not be resurrected from the previous snapshot. - const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); + // A model the installed CLI is too old to run was offered by the pending + // snapshot, before the version was known; retaining it would make it + // selectable again. + const updateRequiredSlugs = new Set(provider.updateRequiredModels?.map((model) => model.slug)); + const retainablePreviousModels = previousModels.filter( + (model) => !model.isCustom && !updateRequiredSlugs.has(model.slug), + ); if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { return retainablePreviousModels; diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 36b7727d8ecb..943dfbb11fe9 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-08T14:44:57Z", + "updatedAt": "2026-10-08T21:30:00Z", "compatibility": [ { "driver": "codex", @@ -112,6 +112,24 @@ "codex": { "profiles": {}, "models": [ + { + "slug": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-astra", + "name": "GPT-6 Astra", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-luna", + "name": "GPT-6 Luna", + "status": "current", + "adapter": { "codex": { "minVersion": "0.155.0" } } + }, { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 53a2e9c45bac..fb6e79a88162 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -6,6 +6,8 @@ import { } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { useAtomValue } from "@effect/atom-react"; +import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; +import { useNavigate } from "@tanstack/react-router"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon } from "lucide-react"; @@ -275,6 +277,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); + const navigate = useNavigate(); const focusSearchInput = useCallback(() => { searchInputRef.current?.focus({ preventScroll: true }); @@ -588,6 +591,21 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ) : []; + // Models the manifest announces that this provider's installed CLI is too + // old to run. Without this, a new model just seems missing until the user + // happens to update the CLI. + // A search spans every instance, so it can explain gated matches from any of them. + const updateRequiredNotices = ( + isSearching + ? instanceEntries.filter(matchesLockedProvider) + : selectedEntry + ? [selectedEntry] + : [] + ).flatMap((entry) => { + const notice = formatProviderUpdateRequiredNotice(entry.snapshot, searchQuery); + return notice ? [{ instanceId: entry.instanceId, notice }] : []; + }); + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { setExpandedLegacyInstances((expanded) => { const next = new Set(expanded); @@ -1076,6 +1094,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ) : ( No models found )} + {updateRequiredNotices.map(({ instanceId, notice }) => ( +

+ {notice}{" "} + { + props.onRequestClose?.(); + void navigate({ to: "/settings/providers" }); + }} + > + Provider settings + +

+ ))}
diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md index 7d3eadae7f58..7c2ffa595df6 100644 --- a/docs/internals/model-manifest.md +++ b/docs/internals/model-manifest.md @@ -16,6 +16,12 @@ built-in catalog. Adding a model with an existing capability profile is a JSON edit; a new profile is needed only for a new capability combination. Codex still gets its model list from its app server. +A model that needs a newer provider CLI should still be announced as soon as it +ships. Claude entries set `adapter.claudeCode.minVersion`; Codex entries set +`adapter.codex.minVersion`, since Codex's own `model/list` cannot name models +released after the installed build. Snapshots report those models in +`updateRequiredModels`, and the picker tells the user which update unlocks them. + `currentModels.claudeAgent` is the current-model classification overlay for releases that predate catalog discovery; it does not add models to their catalogs. Catalog-aware releases use `providers.claudeAgent.models[].status` instead. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index e640c5143a21..aea6ead6b943 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -99,6 +99,10 @@ "types": "./src/providerSkills.ts", "default": "./src/providerSkills.ts" }, + "./providerUpdateRequiredModels": { + "types": "./src/providerUpdateRequiredModels.ts", + "default": "./src/providerUpdateRequiredModels.ts" + }, "./voice-input": { "types": "./src/voice-input/index.ts", "default": "./src/voice-input/index.ts" diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.test.ts b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts new file mode 100644 index 000000000000..5cdf0c28d3d6 --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.test.ts @@ -0,0 +1,47 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatProviderUpdateRequiredNotice } from "./providerUpdateRequiredModels.ts"; + +describe("formatProviderUpdateRequiredNotice", () => { + it("names the CLI and the version that unlocks every listed model", () => { + expect( + formatProviderUpdateRequiredNotice({ + driver: ProviderDriverKind.make("claudeAgent"), + updateRequiredModels: [ + { slug: "a", name: "Model A", minVersion: "2.1.9" }, + { slug: "b", name: "Model B", minVersion: "2.1.10" }, + ], + }), + ).toBe("Update Claude Code to v2.1.10 or newer to use Model A and Model B."); + expect( + formatProviderUpdateRequiredNotice({ driver: ProviderDriverKind.make("codex") }), + ).toBeNull(); + }); + + it("names only the gated models a search matches", () => { + const provider = { + driver: ProviderDriverKind.make("claudeAgent"), + updateRequiredModels: [ + { slug: "claude-a", name: "Model A", minVersion: "2.1.9" }, + { slug: "claude-b", name: "Model B", minVersion: "2.1.10" }, + ], + }; + expect(formatProviderUpdateRequiredNotice(provider, " model a ")).toBe( + "Update Claude Code to v2.1.9 or newer to use Model A.", + ); + expect(formatProviderUpdateRequiredNotice(provider, "gpt")).toBeNull(); + }); + + it("names the release, not a prerelease of it", () => { + expect( + formatProviderUpdateRequiredNotice({ + driver: ProviderDriverKind.make("codex"), + updateRequiredModels: [ + { slug: "a", name: "A", minVersion: "2.1.0-beta" }, + { slug: "b", name: "B", minVersion: "2.1.0" }, + ], + }), + ).toBe("Update the Codex CLI to v2.1.0 or newer to use A and B."); + }); +}); diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.ts b/packages/client-runtime/src/providerUpdateRequiredModels.ts new file mode 100644 index 000000000000..caf44b2bfe53 --- /dev/null +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -0,0 +1,45 @@ +import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +// Name the thing the user updates; "Claude" alone reads like the app or model. +const RUNTIME_NAMES: Partial> = { + claudeAgent: "Claude Code", + codex: "the Codex CLI", +}; + +function formatModelList(names: ReadonlyArray): string { + if (names.length <= 2) return names.join(" and "); + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; +} + +/** + * Model picker notice for models the manifest announces but the installed + * provider is too old to run, e.g. "Update Claude Code to v2.1.300 or newer to + * use Claude Opus 6." With a search query, only gated models it matches are + * named, so searching for one explains why it is missing. Null when nothing + * gated matches. + */ +export function formatProviderUpdateRequiredNotice( + provider: Pick, + searchQuery = "", +): string | null { + const query = searchQuery.trim().toLocaleLowerCase(); + const models = (provider.updateRequiredModels ?? []).filter( + (model) => + query.length === 0 || + model.name.toLocaleLowerCase().includes(query) || + model.slug.toLocaleLowerCase().includes(query), + ); + if (models.length === 0) return null; + // The highest bar unlocks every listed model. + const minVersion = models + .map((model) => model.minVersion) + .reduce((highest, version) => + compareSemverVersions(version, highest) > 0 ? version : highest, + ); + const providerName = + RUNTIME_NAMES[provider.driver] ?? PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; + const version = minVersion.startsWith("v") ? minVersion : `v${minVersion}`; + const names = formatModelList(models.map((model) => model.name)); + return `Update ${providerName} to ${version} or newer to use ${names}.`; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5e33f1aa4c94..e9473dee1df2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -86,6 +86,19 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +/** + * A model the model manifest announces that the installed provider version is + * too old to run. It is never selectable; clients show it so users learn that + * updating the provider unlocks it. + */ +export const ServerProviderUpdateRequiredModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + badge: Schema.optional(Schema.Literal("new")), + minVersion: TrimmedNonEmptyString, +}); +export type ServerProviderUpdateRequiredModel = typeof ServerProviderUpdateRequiredModel.Type; + export const ServerProviderSlashCommandInput = Schema.Struct({ hint: TrimmedNonEmptyString, }); @@ -285,6 +298,8 @@ export const ServerProvider = Schema.Struct({ // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + // Kept apart from `models` so clients that predate it never offer them. + updateRequiredModels: Schema.optionalKey(Schema.Array(ServerProviderUpdateRequiredModel)), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index b29abeed7cc2..1c440a6629e3 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -8,6 +8,7 @@ import type { ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, + ServerProviderUpdateRequiredModel, ServerProviderState, ServerProviderUsageLimits, } from "@t3tools/contracts"; @@ -226,6 +227,7 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + updateRequiredModels?: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; @@ -263,6 +265,9 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + ...(input.updateRequiredModels?.length + ? { updateRequiredModels: input.updateRequiredModels } + : {}), slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), From 48b71f0eb323d08c8fcadfe54adf34becb2984da Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 8 Oct 2026 18:44:05 -0300 Subject: [PATCH 027/377] fix(web): collapsed composer reserves room for wide send actions (#17016) --- apps/web/src/components/chat/ChatComposer.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index cfdb03d0f05c..4a92c9efa881 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5220,6 +5220,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useLayoutEffect(() => { onRestingChange(isComposerResting); }, [isComposerResting, onRestingChange]); + // The resting footer floats over the prompt row, so the row reserves its + // measured width. Wide actions like the compact-before-send chip vary too much for a fixed inset. + const [restingActionsElement, setRestingActionsElement] = useState(null); + const [restingActionsWidth, setRestingActionsWidth] = useState(null); + useLayoutEffect(() => { + if (!isComposerResting || !restingActionsElement) return; + const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(restingActionsElement); + return () => observer.disconnect(); + }, [isComposerResting, restingActionsElement]); const restingImagePreviewCounts = getRestingComposerImagePreviewCounts( standaloneComposerImages.length, ); @@ -7376,17 +7389,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )}
{previewFile ? ( Date: Thu, 8 Oct 2026 15:39:09 -0700 Subject: [PATCH 028/377] fix(muse): workflow subagents no longer stall on hidden approvals (#17329) Co-authored-by: Claude Opus 5.5 --- .../Adapters/MuseAdapterV2.test.ts | 59 ++++++ .../Adapters/MuseAdapterV2.ts | 192 ++++++++++++------ apps/server/src/provider/museProtocol.ts | 2 + 3 files changed, 187 insertions(+), 66 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts index 45a5e00d9bb1..77e12726a183 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts @@ -809,6 +809,65 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("approves a workflow child's approval in full access after its turn ended", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + // Muse runs workflow children without the session's allowAll mode. + const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; + yield* fake.emit("approval/requested", childApproval); + const asked = yield* harness.takeEvent("runtime_request.updated"); + assert.strictEqual(asked.runtimeRequest.status, "pending"); + assert.isNull(asked.runtimeRequest.providerTurnId); + const decision = yield* fake.takeCall("approval/decide"); + assert.strictEqual(decision.params.choiceId, "once"); + yield* fake.emit("approval/resolved", childApproval); + const resolved = yield* harness.takeEvent("runtime_request.updated"); + assert.strictEqual(resolved.runtimeRequest.status, "resolved"); + assert.strictEqual(resolved.runtimeRequest.decision, "accept"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps a workflow child's approval pending for the user past the turn's end", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const policy = ProviderAdapterV2RuntimePolicy.make({ + ...runtimePolicy, + runtimeMode: "approval-required", + }); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + policy, + ); + const { nativeId } = yield* startConversation(harness, fake); + const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; + yield* fake.emit("approval/requested", childApproval); + const asked = yield* harness.takeEvent("runtime_request.updated"); + assert.isNull(asked.runtimeRequest.providerTurnId); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + assert.isFalse( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending", + ), + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: asked.runtimeRequest.id, + decision: "decline", + }); + const decision = yield* fake.takeCall("approval/decide"); + assert.strictEqual(decision.params.choiceId, "deny"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("validates structured answers and preserves selected labels with a custom note", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts index 39d332e4e1b0..b68f90c60a66 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts @@ -18,6 +18,7 @@ import { type ProviderInstanceId, type PlanId, type OrchestrationV2PlanStep, + type ProviderApprovalDecision, type RuntimeRequestId, type ServerProviderModel, } from "@t3tools/contracts"; @@ -157,7 +158,7 @@ const MuseProviderCapabilitiesV2 = { supportsApplyPatchApproval: false, approvalsHaveNativeRequestIds: true, approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: false, + approvalsCanOriginateFromSubagents: true, }, planning: { emitsPlanUpdated: true, @@ -664,6 +665,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte } observedChildren.clear(); if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); + // Only workflow children's requests outlive a turn; they end with the workflow. + for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); }); const resolvePending = Effect.fnUntraced(function* ( entry: PendingRequest, @@ -718,7 +721,9 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); } turn.dirty.clear(); - for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); + // A workflow child's request is runless and keeps waiting after the turn ends. + for (const entry of pending.values()) + if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled"); turn.providerTurn = { ...turn.providerTurn, status, completedAt }; lastProviderTurn = turn.providerTurn; yield* emit({ @@ -801,28 +806,54 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte }); const owns = (turn: ActiveTurn, turnId: string) => turnId === turn.nativeId || turn.joined.has(turnId); + /** Sends T3's decision for a pending approval to Muse. */ + const decideApproval = Effect.fnUntraced(function* ( + entry: PendingRequest, + decision: ProviderApprovalDecision | undefined, + ) { + if (entry.native.type !== "approval") return; + const approval = entry.native.value; + const choice = decision && museApprovalChoices(approval).get(decision); + if (!choice) return yield* protocolError("Muse did not offer this approval decision"); + // Recorded first: Muse can settle the approval before it acknowledges the command. + entry.response = { decision }; + yield* request("approval/decide", { + approvalId: approval.approvalId, + requirementId: approval.currentRequirementId, + choiceId: choice.choiceId, + }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); + }); + /** + * Shows a Muse approval or question as a T3 request. A workflow child's approval + * is runless: the turn that started the workflow may end long before the child + * asks, and T3 keeps a runless request on the thread until Muse settles it. + */ const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { - const turn = active; + const child = native.type === "approval" && native.value.subagentOrigin !== undefined; + const turn = child ? undefined : active; if ( - !turn || + !thread || + (!child && !turn) || native.value.sessionId !== nativeSessionId || - (native.value.turnId && !owns(turn, native.value.turnId)) + (turn && native.value.turnId && !owns(turn, native.value.turnId)) ) return; const nativeId = native.type === "approval" ? native.value.approvalId : native.value.userInputId; - if (turn.settledRequests.has(`${native.type}:${nativeId}`)) return; + if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return; const previous = [...pending.values()].find( (entry) => entry.native.type === native.type && entry.request.nativeRequestRef?.nativeId === nativeId, ); + const providerTurnId = turn?.providerTurn.id ?? null; + const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId; const requestId = previous?.request.id ?? (yield* idAllocator.allocate .runtimeRequest({ driver: MUSE_PROVIDER, - providerTurnId: turn.providerTurn.id, + ...(providerTurnId ? { providerTurnId } : {}), nativeRequestId: nativeId, }) .pipe( @@ -843,7 +874,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte const runtimeRequest: OrchestrationV2RuntimeRequest = { id: requestId, nodeId, - providerTurnId: turn.providerTurn.id, + providerTurnId, nativeRequestRef: nativeRef(nativeId), kind, status: "pending", @@ -853,23 +884,37 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte }; const node: OrchestrationV2ExecutionNode = { id: nodeId, - threadId: turn.input.threadId, - runId: turn.input.runId, - parentNodeId: turn.input.rootNodeId, - rootNodeId: turn.input.rootNodeId, + threadId, + runId: turn?.input.runId ?? null, + parentNodeId: turn?.input.rootNodeId ?? null, + rootNodeId: turn?.input.rootNodeId ?? nodeId, kind: native.type === "approval" ? "approval_request" : "user_input_request", status: "waiting", countsForRun: false, - providerThreadId: turn.input.providerThread.id, - providerTurnId: turn.providerTurn.id, + providerThreadId: thread.id, + providerTurnId, nativeItemRef: nativeRef(nativeId), runtimeRequestId: requestId, checkpointScopeId: null, startedAt: runtimeRequest.createdAt, completedAt: null, }; + const latestTurn = active?.providerTurn ?? lastProviderTurn; const base = { - ...baseItem(turn, `request:${nativeId}`, time), + ...(turn + ? baseItem(turn, `request:${nativeId}`, time) + : { + threadId, + runId: null, + providerThreadId: thread.id, + providerTurnId: null, + nativeItemRef: nativeRef(nativeId), + parentItemId: null, + // Sorts after the latest turn's items. + ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100, + startedAt: runtimeRequest.createdAt, + updatedAt: time, + }), id: idAllocator.derive.approvalTurnItem({ requestId }), nodeId, status: "waiting" as const, @@ -909,16 +954,71 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte required: true, })), }; - pending.set(requestId, { native, request: runtimeRequest, node, item }); + const entry: PendingRequest = { native, request: runtimeRequest, node, item }; + pending.set(requestId, entry); yield* emit({ type: "runtime_request.updated", driver: MUSE_PROVIDER, - threadId: turn.input.threadId, + threadId, runtimeRequest, }); yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node }); yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item }); yield* updateSession("waiting"); + // Muse does not give workflow children the session's allowAll mode, so in full + // access T3 approves for them. If Muse rejects that, the request waits for the user. + if (child && input.runtimePolicy.runtimeMode === "full-access") + yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope)); + }); + /** Approval and question events; `turn` is undefined for a workflow child's approval. */ + const handleRequestEvent = Effect.fnUntraced(function* ( + method: string, + params: Record, + turn: ActiveTurn | undefined, + ) { + switch (method) { + case "approval/requested": + case "approval/updated": + if ( + method === "approval/updated" && + ![...pending.values()].some( + (entry) => + entry.native.type === "approval" && + entry.native.value.approvalId === params.approvalId, + ) + ) + return; + return yield* publishRequest({ + type: "approval", + value: yield* decode(MuseApproval, params), + }); + case "userInput/requested": + return yield* publishRequest({ + type: "question", + value: yield* decode(MuseUserInput, params), + }); + case "approval/resolved": + case "userInput/settled": { + const id = method === "approval/resolved" ? params.approvalId : params.userInputId; + if (typeof id === "string") + turn?.settledRequests.add( + `${method === "approval/resolved" ? "approval" : "question"}:${id}`, + ); + const entry = [...pending.values()].find( + (candidate) => candidate.request.nativeRequestRef?.nativeId === id, + ); + if (entry) { + // An approval settled outside T3 still shows Muse's own decision. + const native = + method === "approval/resolved" && typeof params.decision === "string" + ? museApprovalDecision({ decision: params.decision, scope: "once" }) + : undefined; + if (!entry.response && native) entry.response = { decision: native }; + yield* resolvePending(entry, "resolved"); + } + if (!pending.size) yield* updateSession(active ? "running" : "ready"); + } + } }); const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { const params = yield* decode(recordSchema, data); @@ -962,6 +1062,10 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte return; } } + // A workflow child's approval comes under the child's own run id, during a turn + // or after it, so it skips turn routing. + if (params.subagentOrigin !== undefined && method.startsWith("approval/")) + return yield* handleRequestEvent(method, params, undefined); // Muse starts a turn on its own when a workflow finishes, to report its result. // During a run, that run shows it. Otherwise hold it and ask the orchestrator for // a run; that run takes the held events, unless a user turn takes them first. @@ -1097,45 +1201,11 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte } case "approval/requested": case "approval/updated": - if ( - method === "approval/updated" && - ![...pending.values()].some( - (entry) => - entry.native.type === "approval" && - entry.native.value.approvalId === params.approvalId, - ) - ) - break; - yield* publishRequest({ type: "approval", value: yield* decode(MuseApproval, params) }); - break; - case "userInput/requested": - yield* publishRequest({ - type: "question", - value: yield* decode(MuseUserInput, params), - }); - break; case "approval/resolved": - case "userInput/settled": { - const id = method === "approval/resolved" ? params.approvalId : params.userInputId; - if (typeof id === "string") - turn.settledRequests.add( - `${method === "approval/resolved" ? "approval" : "question"}:${id}`, - ); - const entry = [...pending.values()].find( - (candidate) => candidate.request.nativeRequestRef?.nativeId === id, - ); - if (entry) { - // An approval settled outside T3 still shows Muse's own decision. - const native = - method === "approval/resolved" && typeof params.decision === "string" - ? museApprovalDecision({ decision: params.decision, scope: "once" }) - : undefined; - if (!entry.response && native) entry.response = { decision: native }; - yield* resolvePending(entry, "resolved"); - } - if (!pending.size) yield* updateSession("running"); + case "userInput/requested": + case "userInput/settled": + yield* handleRequestEvent(method, params, turn); break; - } case "session/tokenUsage": { const usage = yield* decode(MuseTokenUsageEvent, params); const previous = turn.providerTurn.turnTokenUsage; @@ -1892,18 +1962,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte Effect.gen(function* () { const entry = pending.get(args.requestId); if (!entry) return yield* protocolError("This Muse request is no longer pending"); - if (entry.native.type === "approval") { - const approval = entry.native.value; - const choice = args.decision && museApprovalChoices(approval).get(args.decision); - if (!choice) return yield* protocolError("Muse did not offer this approval decision"); - // Recorded first: Muse can settle the approval before it acknowledges the command. - entry.response = { decision: args.decision }; - yield* request("approval/decide", { - approvalId: approval.approvalId, - requirementId: approval.currentRequirementId, - choiceId: choice.choiceId, - }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); - } else { + if (entry.native.type === "approval") yield* decideApproval(entry, args.decision); + else { const questionRequest = entry.native.value; const answers = []; for (const question of questionRequest.questions) { diff --git a/apps/server/src/provider/museProtocol.ts b/apps/server/src/provider/museProtocol.ts index 8d82fd9611e8..91befa21e048 100644 --- a/apps/server/src/provider/museProtocol.ts +++ b/apps/server/src/provider/museProtocol.ts @@ -91,6 +91,8 @@ export const MuseApproval = Schema.Struct({ itemId: Schema.optional(Schema.String), toolName: Schema.optional(Schema.String), rawArgs: Schema.optional(Schema.String), + /** Set when a workflow child asks; `turnId` is then the child's run, not a session turn. */ + subagentOrigin: Schema.optional(Schema.Struct({ subagentId: NonEmptyString })), currentRequirementId: Schema.Struct({ approvalId: NonEmptyString, sourceIndex: Schema.Int }), availableChoices: Schema.Array( Schema.Struct({ From e38b6a188fafd499df347331f8c8279ff320c207 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:34:47 -0700 Subject: [PATCH 029/377] refactor(provider-core): share attachment prompts, notifications, and event loggers (#17330) Co-authored-by: Claude Opus 5.5 --- .../scripts/record-grok-acp-replay-fixture.ts | 2 +- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 9 +++--- .../Adapters/ClaudeAdapterV2.ts | 12 ++++--- .../Adapters/CodexAdapterV2.ts | 7 +++-- .../Adapters/CursorAdapterV2.ts | 7 +++-- .../Adapters/MuseAdapterV2.ts | 15 ++++++--- .../Adapters/OpenCode2AdapterV2.ts | 19 +++++++++--- .../Adapters/OpenCodeAdapterV2.ts | 13 +++++--- .../src/orchestration-v2/Orchestrator.ts | 2 +- .../testkit/ReplayRuntimeInstructions.ts | 2 +- .../provider/CodexDeveloperInstructions.ts | 2 +- .../src/provider/Drivers/AntigravityDriver.ts | 2 +- .../src/provider/Drivers/ClaudeDriver.ts | 2 +- .../src/provider/Drivers/CodexDriver.ts | 2 +- .../provider/Drivers/CodexManagedProvider.ts | 2 +- .../src/provider/Drivers/CursorDriver.ts | 2 +- .../server/src/provider/Drivers/GrokDriver.ts | 2 +- .../server/src/provider/Drivers/MuseDriver.ts | 2 +- .../src/provider/Drivers/OpenCodeDriver.ts | 2 +- apps/server/src/provider/EventNdjsonLogger.ts | 7 ++--- .../src/provider/ProviderEventLoggers.ts | 31 +++---------------- packages/provider-core/package.json | 20 ++++++++++++ .../src/server/ProviderEventLoggers.ts | 30 ++++++++++++++++++ .../src/server/attachmentPrompt.test.ts | 14 ++++++--- .../src/server/attachmentPrompt.ts | 10 ++---- .../src/server}/instanceIdentity.ts | 0 .../src/server/notification.test.ts | 2 +- .../provider-core/src/server/notification.ts | 0 .../src/server/runtimeInstructions.test.ts | 2 +- .../src/server/runtimeInstructions.ts | 0 30 files changed, 138 insertions(+), 84 deletions(-) create mode 100644 packages/provider-core/src/server/ProviderEventLoggers.ts rename apps/server/src/orchestration-v2/AttachmentPrompt.test.ts => packages/provider-core/src/server/attachmentPrompt.test.ts (92%) rename apps/server/src/orchestration-v2/AttachmentPrompt.ts => packages/provider-core/src/server/attachmentPrompt.ts (96%) rename {apps/server/src/provider/Drivers => packages/provider-core/src/server}/instanceIdentity.ts (100%) rename apps/server/src/orchestration-v2/Notification.test.ts => packages/provider-core/src/server/notification.test.ts (99%) rename apps/server/src/orchestration-v2/Notification.ts => packages/provider-core/src/server/notification.ts (100%) rename apps/server/src/provider/RuntimeInstructions.test.ts => packages/provider-core/src/server/runtimeInstructions.test.ts (96%) rename apps/server/src/provider/RuntimeInstructions.ts => packages/provider-core/src/server/runtimeInstructions.ts (100%) diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index a6b88d3024ff..23d687eb9130 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -44,7 +44,7 @@ import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/Provider import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; -import { buildRuntimeInstructions } from "../src/provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; const wallClock = Clock.Clock.defaultValue(); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 2767e0e1b41d..90255fe75a78 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -98,14 +98,14 @@ import { t3AcpPromptWithInstructions, type T3AcpInstructionState, } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { type BackgroundWork, type BackgroundWorkReport, backgroundWorkNotification, -} from "../Notification.ts"; +} from "@t3tools/provider-core/server/notification"; import { makeProviderFailure, makeProviderRetryTurnItem, @@ -114,7 +114,7 @@ import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionT import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, -} from "../AttachmentPrompt.ts"; +} from "@t3tools/provider-core/server/attachmentPrompt"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, @@ -6781,7 +6781,8 @@ export function makeAcpAdapterV2( const messageText = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); const text = t3AcpPromptWithInstructions({ prompt: messageText, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0b33327b1850..50fab3e191db 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -112,7 +112,7 @@ import { import type { ServerProviderShape } from "@t3tools/provider-core/server/snapshot"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { mcpToolPresentation, normalizeMcpText, @@ -124,14 +124,17 @@ import { makeProviderRetryTurnItem, } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, } from "@t3tools/provider-core/server/adapterDriver"; -import { type BackgroundWorkReport, backgroundWorkNotification } from "../Notification.ts"; +import { + type BackgroundWorkReport, + backgroundWorkNotification, +} from "@t3tools/provider-core/server/notification"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeSubagentChildThread, @@ -1344,7 +1347,8 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: const textWithAttachmentPaths = providerMessageTextWithAttachmentPaths({ text: input.text, attachments: input.attachments, - attachmentsDir: input.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment }), }); const dispatch = diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 660e690b5887..5318971e89d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -126,7 +126,7 @@ import { type ProviderContinuationRequest, ProviderContinuationRequests, } from "@t3tools/provider-core/server/continuationRequests"; -import { backgroundWorkNotification } from "../Notification.ts"; +import { backgroundWorkNotification } from "@t3tools/provider-core/server/notification"; import { makeProviderFailure, makeProviderFailureTurnItem, @@ -136,7 +136,7 @@ import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/sel import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, -} from "../AttachmentPrompt.ts"; +} from "@t3tools/provider-core/server/attachmentPrompt"; import { ProviderAdapterEnsureThreadError, ProviderAdapterForkThreadError, @@ -3150,7 +3150,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi const text = providerMessageTextWithAttachmentPaths({ text: codexSkillMentionText(turnInput.message.text), attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); if (text.length > 0) { inputItems.push({ diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 077cb6ead337..53a1d260cc83 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -55,14 +55,14 @@ import { } from "../../provider/Drivers/CursorSkills.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, -} from "../AttachmentPrompt.ts"; +} from "@t3tools/provider-core/server/attachmentPrompt"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, @@ -2133,7 +2133,8 @@ export function makeCursorAdapterV2( ? rawText : rewriteCursorSkillMentions(rawText, cursorSkillNames), attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }), runOrdinal: turnInput.runOrdinal, hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts index b68f90c60a66..30d7073463a7 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts @@ -38,7 +38,7 @@ import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { museModelCapabilities, resolveMuseReasoningEffort, @@ -71,7 +71,7 @@ import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; import { providerMessageTextWithAttachmentPaths, isProviderNativeImageAttachment, -} from "../AttachmentPrompt.ts"; +} from "@t3tools/provider-core/server/attachmentPrompt"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterEnsureThreadError, @@ -96,7 +96,10 @@ import { type ProviderAdapterV2TurnInput, type ProviderAdapterV2TurnMessage, } from "@t3tools/provider-core/server/ProviderAdapter"; -import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; +import { + backgroundWorkNotification, + type BackgroundWorkReport, +} from "@t3tools/provider-core/server/notification"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; @@ -1598,7 +1601,11 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte const text = providerMessageTextWithAttachmentPaths({ text: message.text, attachments: message.attachments, - attachmentsDir: options.serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: options.serverConfig.attachmentsDir, + attachment, + }), }); if (text) parts.push({ type: "text", text }); for (const attachment of message.attachments) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 5806fc5fb68d..6bc194b811a4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -70,6 +70,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; @@ -78,16 +79,19 @@ import { type OpenCodeRuntimeError, } from "../../provider/opencodeRuntime.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; -import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { backgroundWorkNotification, type BackgroundWorkReport } from "../Notification.ts"; +import { + backgroundWorkNotification, + type BackgroundWorkReport, +} from "@t3tools/provider-core/server/notification"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { @@ -3212,7 +3216,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }).trim(); const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => @@ -3893,7 +3898,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr text: providerMessageTextWithAttachmentPaths({ text: steerInput.message.text, attachments: steerInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }), }).trim(), delivery: "steer", }) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 8b3216fbaaa7..c746bec2180e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -66,12 +66,12 @@ import { } from "../../provider/NativeProtocolLogging.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, @@ -2853,7 +2853,8 @@ export function makeOpenCodeAdapterV2( const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }).trim(); const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: turnInput.message.attachments, @@ -3356,7 +3357,11 @@ export function makeOpenCodeAdapterV2( const text = providerMessageTextWithAttachmentPaths({ text: steerInput.message.text, attachments: steerInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }), }).trim(); const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: steerInput.message.attachments, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a2e45ed21c51..54127e20bc7a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -85,7 +85,7 @@ import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, resolveMessageDispatchIntent } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; -import { notificationTurnItem } from "./Notification.ts"; +import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts index 18f87d1f7a4f..31fa33f055d2 100644 --- a/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts +++ b/apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts @@ -1,6 +1,6 @@ import type { ProviderDriverKind, ProviderReplayTranscript } from "@t3tools/contracts"; -import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; /** Adds current runtime context to legacy prompt expectations, keeping outbound matching exact. */ export function materializeReplayTranscriptRuntimeInstructions( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index e9cbe27997ab..f80aefe98a70 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,6 +1,6 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; -import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 238834f63a11..f354dc4b4aa7 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -60,7 +60,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ab9e6d2e8f2f..aea45e37cb9b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -50,7 +50,7 @@ import { type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 0d6da5a5dea6..b007785a6e82 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -51,7 +51,7 @@ import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index b7c12ff3391d..32d88e644879 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -21,7 +21,7 @@ import { type ProviderInstance, } from "@t3tools/provider-core/server/driver"; import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { HttpClient } from "effect/http"; const DRIVER = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 21bc7a0b7af8..cf4d82bcfdb1 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -35,7 +35,7 @@ import { type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5962c7248264..f7226d5f1e9f 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -28,7 +28,7 @@ import { type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { discoverGrokSkills } from "./GrokSkills.ts"; import { diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/apps/server/src/provider/Drivers/MuseDriver.ts index e22b22ab0dee..16bed1c3dab2 100644 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ b/apps/server/src/provider/Drivers/MuseDriver.ts @@ -35,7 +35,7 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; const DRIVER_KIND = ProviderDriverKind.make("muse"); const decodeMuseSettings = Schema.decodeSync(MuseSettings); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 76a9dcdc7b78..83347d86a87a 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -59,7 +59,7 @@ import { type ProviderDriver, type ProviderInstance, } from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { enrichProviderSnapshotWithVersionAdvisory, diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 491119c3cdfd..1ec5c46d9324 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -9,6 +9,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ThreadId } from "@t3tools/contracts"; +import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { errorTag } from "@t3tools/shared/observability"; import * as Clock from "effect/Clock"; @@ -63,11 +64,7 @@ const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk export type EventNdjsonStream = "native" | "canonical" | "orchestration"; -export interface EventNdjsonLogger { - readonly filePath: string; - readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; - readonly close: () => Effect.Effect; -} +export type { EventNdjsonLogger }; export interface EventNdjsonLogStore { readonly filePath: string; diff --git a/apps/server/src/provider/ProviderEventLoggers.ts b/apps/server/src/provider/ProviderEventLoggers.ts index 1cb96058d3b2..b31acbef952b 100644 --- a/apps/server/src/provider/ProviderEventLoggers.ts +++ b/apps/server/src/provider/ProviderEventLoggers.ts @@ -24,39 +24,18 @@ * * @module provider/ProviderEventLoggers */ -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; -import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; - -/** - * Shared logger pair for native + canonical provider event streams. - * - * Service value is intentionally a struct of two optional loggers rather - * than two parallel tags. Construction site is one place - * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the - * field they need. - */ -export class ProviderEventLoggers extends Context.Service< +import { + NoOpProviderEventLoggers, ProviderEventLoggers, - { - readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; - readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; - } ->()("t3/provider/ProviderEventLoggers") {} +} from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; -/** - * Constant value used by tests / boot layers that want to opt out of native - * + canonical logging entirely. Keeps the tag non-optional in the type - * system while letting the runtime treat absence as a no-op. - */ -export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { - native: undefined, - canonical: undefined, -}; +export { NoOpProviderEventLoggers, ProviderEventLoggers }; /** * Builds both stream views over one shared store. Setup failures are logged diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index 073faf381440..fa21d1f2efee 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -11,6 +11,10 @@ "types": "./src/server/adapterDriver.ts", "import": "./src/server/adapterDriver.ts" }, + "./server/attachmentPrompt": { + "types": "./src/server/attachmentPrompt.ts", + "import": "./src/server/attachmentPrompt.ts" + }, "./server/auth": { "types": "./src/server/auth.ts", "import": "./src/server/auth.ts" @@ -47,6 +51,10 @@ "types": "./src/server/instanceEnvironment.ts", "import": "./src/server/instanceEnvironment.ts" }, + "./server/instanceIdentity": { + "types": "./src/server/instanceIdentity.ts", + "import": "./src/server/instanceIdentity.ts" + }, "./server/maintenance": { "types": "./src/server/maintenance.ts", "import": "./src/server/maintenance.ts" @@ -67,6 +75,10 @@ "types": "./src/server/mcpToolPresentation.ts", "import": "./src/server/mcpToolPresentation.ts" }, + "./server/notification": { + "types": "./src/server/notification.ts", + "import": "./src/server/notification.ts" + }, "./server/orchestrationInstructions": { "types": "./src/server/orchestrationInstructions.ts", "import": "./src/server/orchestrationInstructions.ts" @@ -83,6 +95,10 @@ "types": "./src/server/ProviderAdapter.ts", "import": "./src/server/ProviderAdapter.ts" }, + "./server/ProviderEventLoggers": { + "types": "./src/server/ProviderEventLoggers.ts", + "import": "./src/server/ProviderEventLoggers.ts" + }, "./server/ProviderHost": { "types": "./src/server/ProviderHost.ts", "import": "./src/server/ProviderHost.ts" @@ -91,6 +107,10 @@ "types": "./src/server/randomUuid.ts", "import": "./src/server/randomUuid.ts" }, + "./server/runtimeInstructions": { + "types": "./src/server/runtimeInstructions.ts", + "import": "./src/server/runtimeInstructions.ts" + }, "./server/selectionTransition": { "types": "./src/server/selectionTransition.ts", "import": "./src/server/selectionTransition.ts" diff --git a/packages/provider-core/src/server/ProviderEventLoggers.ts b/packages/provider-core/src/server/ProviderEventLoggers.ts new file mode 100644 index 000000000000..6341bd96af1b --- /dev/null +++ b/packages/provider-core/src/server/ProviderEventLoggers.ts @@ -0,0 +1,30 @@ +/** + * Provider event logging as the server offers it to drivers: one stream for + * the provider's native protocol frames and one for canonical orchestration + * events. Either logger is absent when diagnostics logging could not start. + * + * @module provider-core/server/ProviderEventLoggers + */ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface EventNdjsonLogger { + readonly filePath: string; + readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect; + readonly close: () => Effect.Effect; +} + +export class ProviderEventLoggers extends Context.Service< + ProviderEventLoggers, + { + readonly native: EventNdjsonLogger | undefined; + readonly canonical: EventNdjsonLogger | undefined; + } +>()("@t3tools/provider-core/server/ProviderEventLoggers") {} + +/** Logging turned off: for tests and boot layers that skip diagnostics. */ +export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { + native: undefined, + canonical: undefined, +}; diff --git a/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts b/packages/provider-core/src/server/attachmentPrompt.test.ts similarity index 92% rename from apps/server/src/orchestration-v2/AttachmentPrompt.test.ts rename to packages/provider-core/src/server/attachmentPrompt.test.ts index 03c35bd4ef9b..e31c8417f202 100644 --- a/apps/server/src/orchestration-v2/AttachmentPrompt.test.ts +++ b/packages/provider-core/src/server/attachmentPrompt.test.ts @@ -9,7 +9,7 @@ import { assert, describe, it } from "@effect/vitest"; import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, -} from "./AttachmentPrompt.ts"; +} from "./attachmentPrompt.ts"; const document = ChatFileAttachment.make({ id: ChatAttachmentId.make("file-document"), @@ -26,13 +26,17 @@ const image = ChatImageAttachment.make({ sizeBytes: 456, }); +// Stands in for the host's resolver, which maps stored attachments to their files. +const resolveAttachmentPath = (attachment: { readonly id: string; readonly name: string }) => + `/attachments/${attachment.id}${attachment.name.slice(attachment.name.lastIndexOf("."))}`; + describe("provider attachment prompts", () => { it("appends resolvable file paths for documents and images", () => { assert.equal( providerMessageTextWithAttachmentPaths({ text: "Review these.", attachments: [document, image], - attachmentsDir: "/attachments", + resolveAttachmentPath, }), 'Review these.\n\n[Attached file "spec.pdf" is saved at: /attachments/file-document.pdf]\n' + '\n[Attached image "diagram.png" is saved at: /attachments/file-image.png]', @@ -53,7 +57,7 @@ describe("provider attachment prompts", () => { const text = providerMessageTextWithAttachmentPaths({ text: "Fix this.", attachments: [captured], - attachmentsDir: "/attachments", + resolveAttachmentPath, }); assert.include( @@ -103,7 +107,7 @@ describe("provider attachment prompts", () => { const text = providerMessageTextWithAttachmentPaths({ text: "Describe this.", attachments: [captured], - attachmentsDir: "/attachments", + resolveAttachmentPath, }); assert.include( @@ -133,7 +137,7 @@ describe("provider attachment prompts", () => { const text = providerMessageTextWithAttachmentPaths({ text: "Fix this.", attachments, - attachmentsDir: "/attachments", + resolveAttachmentPath, }); assert.isAtMost(text.length, PROVIDER_SEND_TURN_MAX_INPUT_CHARS); diff --git a/apps/server/src/orchestration-v2/AttachmentPrompt.ts b/packages/provider-core/src/server/attachmentPrompt.ts similarity index 96% rename from apps/server/src/orchestration-v2/AttachmentPrompt.ts rename to packages/provider-core/src/server/attachmentPrompt.ts index 7189ccf763f9..1b09911f50d8 100644 --- a/apps/server/src/orchestration-v2/AttachmentPrompt.ts +++ b/packages/provider-core/src/server/attachmentPrompt.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; - const encodePromptJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); interface SnapShotPromptAccessibilityNode { @@ -161,7 +159,8 @@ export function isProviderNativeImageAttachment(attachment: ChatAttachment): boo export function providerMessageTextWithAttachmentPaths(input: { readonly text: string; readonly attachments: ReadonlyArray; - readonly attachmentsDir: string; + /** Absolute path of a stored attachment, such as `ProviderHost.resolveAttachmentPath`. */ + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; }): string { let text = input.text; const appendContext = (context: string | undefined) => { @@ -171,10 +170,7 @@ export function providerMessageTextWithAttachmentPaths(input: { }; for (const attachment of input.attachments) { - const path = resolveAttachmentPath({ - attachmentsDir: input.attachmentsDir, - attachment, - }); + const path = input.resolveAttachmentPath(attachment); appendContext( path === null ? undefined diff --git a/apps/server/src/provider/Drivers/instanceIdentity.ts b/packages/provider-core/src/server/instanceIdentity.ts similarity index 100% rename from apps/server/src/provider/Drivers/instanceIdentity.ts rename to packages/provider-core/src/server/instanceIdentity.ts diff --git a/apps/server/src/orchestration-v2/Notification.test.ts b/packages/provider-core/src/server/notification.test.ts similarity index 99% rename from apps/server/src/orchestration-v2/Notification.test.ts rename to packages/provider-core/src/server/notification.test.ts index 197587a786e9..a67cab6b7e29 100644 --- a/apps/server/src/orchestration-v2/Notification.test.ts +++ b/packages/provider-core/src/server/notification.test.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { backgroundWorkNotification, notificationTurnItem } from "./Notification.ts"; +import { backgroundWorkNotification, notificationTurnItem } from "./notification.ts"; const childThreadId = ThreadId.make("thread:child"); diff --git a/apps/server/src/orchestration-v2/Notification.ts b/packages/provider-core/src/server/notification.ts similarity index 100% rename from apps/server/src/orchestration-v2/Notification.ts rename to packages/provider-core/src/server/notification.ts diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/packages/provider-core/src/server/runtimeInstructions.test.ts similarity index 96% rename from apps/server/src/provider/RuntimeInstructions.test.ts rename to packages/provider-core/src/server/runtimeInstructions.test.ts index 10f8413b5a13..6ad3b027e047 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/packages/provider-core/src/server/runtimeInstructions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; +import { buildRuntimeInstructions } from "./runtimeInstructions.ts"; describe("buildRuntimeInstructions", () => { it("requires explicit registration of every PR and stack layer", () => { diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts similarity index 100% rename from apps/server/src/provider/RuntimeInstructions.ts rename to packages/provider-core/src/server/runtimeInstructions.ts From 741377ce5fb1578916659cbb93fd403b26dbeb62 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:37:25 -0700 Subject: [PATCH 030/377] fix(web): file previews handle downloads, in-page links, and repo paths, and favicons stop leaking internal hosts (#16950) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/http.test.ts | 4 +- apps/server/src/http.ts | 6 +- apps/web/src/components/ChatMarkdown.test.tsx | 82 ++++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 81 ++++++++++++++++-- .../components/files/BrowserDocumentFrame.tsx | 2 +- apps/web/src/markdown-links.test.ts | 29 +++++++ apps/web/src/markdown-links.ts | 23 +++++- packages/shared/src/favicon.test.ts | 9 +- packages/shared/src/favicon.ts | 10 ++- packages/shared/src/hostClassification.ts | 22 ++++- packages/shared/src/markdownLinks.ts | 2 +- 11 files changed, 245 insertions(+), 25 deletions(-) diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9e710d89717e..6ea97f243038 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -674,14 +674,14 @@ describe("assetResponseHeaders", () => { assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }), ).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); }); it("serves HTML assets as utf-8 inside a sandboxed origin", () => { for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { expect(assetResponseHeaders(path)).toMatchObject({ "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); } }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 057e64e96d9d..de4ce26cc915 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -57,8 +57,10 @@ const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inlin // opaque origin: scripts run, but same-origin cookies, storage, and API calls are // out of reach. Relative sibling assets still load through their signed URLs. // No modals: agent HTML can open without a click (inline renders, and mobile -// loads it as the top document), and must not raise blocking dialogs. -const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups"; +// loads it as the top document), and must not raise blocking dialogs. Downloads +// stay allowed so download links and buttons in the page work. +const HTML_CONTENT_SECURITY_POLICY = + "sandbox allow-scripts allow-forms allow-popups allow-downloads"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 08fb61eac0bb..b62431a08383 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -871,15 +871,15 @@ describe("ChatMarkdown heading levels", () => { />, ); - expect(html).toContain('

Top

'); - expect(html).toContain('

Section

'); - expect(html).toContain('
Fine print
'); + expect(html).toContain('

Top

'); + expect(html).toContain('

Section

'); + expect(html).toContain('
Fine print
'); }); it("leaves heading levels alone when the markdown is not nested", () => { const html = renderToStaticMarkup(); - expect(html).toContain("

Top

"); + expect(html).toContain('

Top

'); }); }); @@ -1272,3 +1272,77 @@ it.each([ } }, ); + +describe("ChatMarkdown heading ids", () => { + it("never gives two headings the same id, even when a suffix matches another heading", () => { + const html = renderToStaticMarkup( + Pinned\n\n## Install\n\n## Install' + } + />, + ); + const ids = [...html.matchAll(/

match[1]); + expect(ids).toEqual([ + "user-content-setup", + "user-content-setup-1", + "user-content-setup-1-1", + "user-content-install-1", + "user-content-install", + "user-content-install-2", + ]); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("ChatMarkdown in-page links", () => { + it.each([true, false])( + "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", + async (parseRawHtml) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + window.history.replaceState(null, "", "/#/env/thread"); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + try { + await act(async () => { + root.render( + , + ); + }); + const headings = [...container.querySelectorAll("h2")]; + expect(headings.map((heading) => heading.id)).toEqual([ + "user-content-1-operating-model", + "user-content-1-operating-model-1", + ]); + + const [tocLink, missingLink] = [...container.querySelectorAll("a")]; + const click = () => new MouseEvent("click", { bubbles: true, cancelable: true }); + const tocClick = click(); + tocLink!.dispatchEvent(tocClick); + expect(tocClick.defaultPrevented).toBe(true); + expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); + + const missingClick = click(); + missingLink!.dispatchEvent(missingClick); + expect(missingClick.defaultPrevented).toBe(true); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(window.location.hash).toBe("#/env/thread"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }, + ); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42b4a0618b6b..4c712365475d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1807,16 +1807,77 @@ function handleMarkdownFragmentClick(event: ReactMouseEvent, return; } - const target = findMarkdownFragmentTarget(event.currentTarget, href); - if (!target) return; - + // Never let the browser follow the fragment or write it to the URL: desktop keeps + // its route in the hash, so replacing the hash navigates away from the thread. event.preventDefault(); - const nextUrl = new URL(window.location.href); - nextUrl.hash = href.slice(1); - window.history.pushState(window.history.state, "", nextUrl); - target.scrollIntoView({ block: "nearest" }); + findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); +} + +type HeadingHastNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: HeadingHastNode[]; +}; + +/** GitHub's heading anchor slug, so `[Setup](#setup)` table-of-contents links find their heading. */ +function githubHeadingSlug(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") + .replace(/ /g, "-"); +} + +/** + * Gives headings without an authored id GitHub's slug id, deduplicated per document. Like the + * sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app element ids; + * fragment lookup strips it. + */ +function rehypeHeadingIds() { + return (tree: HeadingHastNode) => { + // Every id already in the document, authored or assigned, so a suffix never + // lands on one that exists: `Setup`, `Setup`, `Setup-1` get three distinct ids. + const taken = new Set(); + const collect = (node: HeadingHastNode) => { + const id = node.properties?.id; + if (typeof id === "string") taken.add(id); + node.children?.forEach(collect); + }; + collect(tree); + const nextSuffix = new Map(); + const visit = (node: HeadingHastNode) => { + if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { + const slug = githubHeadingSlug(hastPlainTextDeep(node)); + if (node.properties?.id === undefined && slug) { + let count = nextSuffix.get(slug) ?? 0; + let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; + while (taken.has(id)) { + count += 1; + id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; + } + nextSuffix.set(slug, count); + taken.add(id); + node.properties = { ...node.properties, id }; + } + return; + } + node.children?.forEach(visit); + }; + visit(tree); + }; } +// Heading ids are added after sanitizing, which would prefix them a second time. +const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ + ...CHAT_MARKDOWN_REHYPE_PLUGINS, + rehypeHeadingIds, +] satisfies NonNullable; + +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + function MarkdownExternalLinkContent({ host, plainText, @@ -3363,7 +3424,11 @@ function ChatMarkdown({ ); } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index a8e01e834c7a..3aaed9058c01 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -91,6 +91,35 @@ describe("relative links inside a rendered host file", () => { workspaceRelativePath: "docs/src/main.ts", }); }); + + it("resolve multi-segment inline code from the workspace root in a workspace file", () => { + expect( + resolveInlineCodeFileLinkMeta("docs/ai/design.md", "/repo", "/repo/docs/ai"), + ).toMatchObject({ filePath: "/repo/docs/ai/design.md" }); + expect( + resolveInlineCodeFileLinkMeta("src/index.ts:4", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/src/index.ts", line: 4 }); + }); + + it("keep sibling and explicitly relative inline code beside the file", () => { + expect(resolveInlineCodeFileLinkMeta("design.md:12", "/repo", "/repo/docs/ai")).toMatchObject({ + filePath: "/repo/docs/ai/design.md", + line: 12, + }); + expect( + resolveInlineCodeFileLinkMeta("./src/index.ts", "/repo", "/repo/packages/a"), + ).toMatchObject({ filePath: "/repo/packages/a/./src/index.ts" }); + expect(resolveInlineCodeFileLinkMeta("../b/notes.md", "/repo", "/repo/docs/a")).toMatchObject({ + filePath: "/repo/docs/a/../b/notes.md", + }); + }); + + it("keep multi-segment inline code beside a file outside the workspace", () => { + expect(resolveInlineCodeFileLinkMeta("src/main.ts", "/repo", "/tmp/report")).toMatchObject({ + filePath: "/tmp/report/src/main.ts", + workspaceRelativePath: null, + }); + }); }); describe("resolveInlineCodeFileLinkMeta", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d25d089f6528..3aa188d27ec2 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,6 +1,7 @@ import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; import { inlineCodeFilePathCandidate, + isRelativeFilePath, normalizeMarkdownLinkDestination, resolveMarkdownFileLinkTarget, } from "@t3tools/shared/markdownLinks"; @@ -50,7 +51,27 @@ export function resolveInlineCodeFileLinkMeta( const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - return resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); + return resolveMarkdownFileLinkMeta( + candidate, + cwd, + inlineCodePathNamesFromWorkspaceRoot(candidate, cwd, baseDir) ? cwd : baseDir, + ); +} + +/** + * Prose in a workspace file names other files from the repo root (`docs/ai/design.md`), + * unlike an explicit link. Single-segment names (`design.md:12`) and `./`, `../` + * paths still read as siblings, and files outside the workspace keep their own base. + */ +function inlineCodePathNamesFromWorkspaceRoot( + candidate: string, + cwd: string | undefined, + baseDir: string | undefined, +): boolean { + if (!cwd || !baseDir || !isRelativeFilePath(candidate)) return false; + if (/^(?:~|\.{1,2})\//.test(candidate)) return false; + if (!splitFilePathPosition(candidate).path.includes("/")) return false; + return workspaceRelativeFilePath(baseDir, cwd) !== null; } export function resolveMarkdownFileLinkMeta( diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index 676f7811011e..e9b4607ca89b 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -23,13 +23,18 @@ describe("faviconUrlForOrigin", () => { "http://service.test", "http://private.onion", "http://127.1..", + "https://grafana.corp", + "https://build.lan", + "https://wiki.intranet", + "https://grafana.internal.acme-corp.com", + "https://jira.corp.acme-corp.com:8443", ])("does not disclose %s to the favicon provider", (origin) => { expect(faviconUrlForOrigin(origin)).toBeNull(); }); - it("keeps the public origin, port and requested size", () => { + it("sends only the public hostname and requested size, never the port", () => { expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe( - "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64", + "https://www.google.com/s2/favicons?domain=github.com&sz=64", ); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index 2c4847115b90..877f178d6072 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -82,15 +82,19 @@ export function toolActivityFaviconUrl( ); } -/** Return a public favicon URL without disclosing private or reserved hosts. */ +/** + * Return a public favicon URL that discloses only a public hostname, never the + * port, path, or a private or internal-looking host. Callers show a generic + * icon when this returns null. + */ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { if (!rawUrl) return null; try { const url = new URL(rawUrl); - if (!url.host) return null; + if (!url.hostname) return null; if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isPublicFaviconHost(url.hostname)) return null; - return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`; + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=${size}`; } catch { return null; } diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index ac56cd640af9..3f6dee4bf9da 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -119,6 +119,23 @@ export const isPrivateNetworkHost = (host: string): boolean => { ); }; +// Reserved and special-use names, plus the private TLDs RFC 6762 Appendix G +// records as common on internal networks. +const PRIVATE_FAVICON_TLDS = [ + ".alt", + ".corp", + ".example", + ".home", + ".internal", + ".intranet", + ".invalid", + ".lan", + ".onion", + ".private", + ".test", +]; +const INTERNAL_HOST_LABELS: ReadonlySet = new Set(["corp", "internal", "intranet"]); + /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ export const isPublicFaviconHost = (host: string): boolean => { // A single trailing dot is a valid absolute DNS name. Repeated trailing @@ -127,12 +144,15 @@ export const isPublicFaviconHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (isPrivateNetworkHost(normalized)) return false; if ( - [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + PRIVATE_FAVICON_TLDS.some( (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), ) ) { return false; } + // Company networks often nest internal services under a public domain + // (`grafana.internal.acme.com`); never send those names to a third party. + if (normalized.split(".").some((label) => INTERNAL_HOST_LABELS.has(label))) return false; const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); if (!normalized.includes(":")) return true; diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 875f764d448d..4055ccb08967 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -252,7 +252,7 @@ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); } -function isRelativeFilePath(path: string): boolean { +export function isRelativeFilePath(path: string): boolean { return ( RELATIVE_PATH_PREFIX_PATTERN.test(path) || (!path.startsWith("/") && !isWindowsAbsolutePath(path)) From 5754121c23f73201d47a96feb97eca7b96334a2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:37:46 -0700 Subject: [PATCH 031/377] fix(server): environment-hosted browser tabs behave like a normal browser (#16963) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/EffectOutbox.ts | 5 + .../src/orchestration-v2/EffectWorker.ts | 11 + .../ResourceCleanupService.ts | 16 +- .../orchestration-v2/ThreadDeletion.test.ts | 1 + .../src/orchestration-v2/ThreadDeletion.ts | 6 + .../src/preview/DesktopBrowserChannel.ts | 7 + apps/server/src/preview/Manager.ts | 3 + apps/server/src/preview/PortScanner.test.ts | 114 ++++++++ apps/server/src/preview/PortScanner.ts | 161 ++++++++++- .../src/preview/PreviewBrowserHost.test.ts | 36 ++- apps/server/src/preview/PreviewBrowserHost.ts | 33 ++- apps/server/src/preview/ServerBrowser.test.ts | 255 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 148 +++++++++- .../src/preview/ServerBrowserContexts.ts | 66 ++++- .../src/preview/ServerBrowserPage.test.ts | 31 +++ apps/server/src/preview/ServerBrowserPage.ts | 28 +- .../server/src/preview/ServerBrowserStream.ts | 1 + .../project/ProjectService.deletion.test.ts | 18 +- apps/web/src/browser/ServerBrowserSurface.tsx | 5 + .../components/preview/PreviewUnreachable.tsx | 44 +++ .../src/components/preview/PreviewView.tsx | 52 +++- .../preview/ThreadPreviewMiniPlayer.tsx | 2 + .../components/preview/showPreviewPopup.ts | 24 ++ apps/web/src/hooks/useThreadActions.ts | 3 + apps/web/src/previewStateStore.ts | 40 +++ .../src/preview/serverBrowserStream.ts | 8 +- packages/contracts/src/preview.ts | 10 + packages/shared/src/preview.test.ts | 50 ++++ packages/shared/src/preview.ts | 68 +++++ 29 files changed, 1202 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/preview/showPreviewPopup.ts diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 33f59497eb20..d9b21038cf71 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -95,6 +95,10 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ Schema.Struct({ type: Schema.Literal("terminal.cleanup"), }), + /** Closes a deleted thread's preview sessions, ending their server browser tabs. */ + Schema.Struct({ + type: Schema.Literal("preview.cleanup"), + }), Schema.Struct({ type: Schema.Literal("attachment.cleanup"), attachmentIds: Schema.Array(Schema.String), @@ -120,6 +124,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [ "provider-thread.rollback", "checkpoint.capture", "terminal.cleanup", + "preview.cleanup", "attachment.cleanup", "thread-title.generate", "delegated-tasks.stop", diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index b3ee780334d3..9ae94910b7c8 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -439,6 +439,17 @@ export const layerExecutor: Layer.Layer< }), ), ); + case "preview.cleanup": + return resourceCleanup.cleanupPreviews(effect.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); case "attachment.cleanup": return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index 117d05466fb6..13310b781892 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -6,12 +7,13 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as PreviewManager from "../preview/Manager.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "attachment"]), + operation: Schema.Literals(["terminal", "preview", "attachment"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -20,12 +22,15 @@ export class ResourceCleanupError extends Schema.TaggedError Effect.Effect; + /** Closes every preview session of the thread; server browser tabs end with them. */ + readonly cleanupPreviews: (threadId: string) => Effect.Effect; readonly cleanupAttachments: ( attachmentIds: ReadonlyArray, ) => Effect.Effect; }>("t3/orchestration-v2/ResourceCleanupService", { defaultValue: () => ({ cleanupTerminals: () => Effect.void, + cleanupPreviews: () => Effect.void, cleanupAttachments: () => Effect.void, }), }) {} @@ -34,6 +39,7 @@ export const layer = Layer.effect( ResourceCleanupService, Effect.gen(function* () { const terminals = yield* TerminalManager.TerminalManager; + const previews = yield* PreviewManager.PreviewManager; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; return { @@ -45,6 +51,14 @@ export const layer = Layer.effect( (cause) => new ResourceCleanupError({ operation: "terminal", threadId, cause }), ), ), + cleanupPreviews: (threadId: string) => + previews + .close({ threadId: ThreadId.make(threadId) }) + .pipe( + Effect.mapError( + (cause) => new ResourceCleanupError({ operation: "preview", threadId, cause }), + ), + ), cleanupAttachments: (attachmentIds: ReadonlyArray) => Effect.forEach( attachmentIds, diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index 3a502e9e1b96..da24f247f902 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -291,6 +291,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio revokeMcpCredential: true, }, { type: "terminal.cleanup" }, + { type: "preview.cleanup" }, { type: "attachment.cleanup", attachmentIds: ["shared_file"] }, ], ); diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 077aa5726bd0..fca9c05ae3e7 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -215,6 +215,12 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") threadId: command.threadId, request: { type: "terminal.cleanup" }, }); + effects.push({ + id: `effect:${command.commandId}:preview.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "preview.cleanup" }, + }); const attachmentIds = Array.from(new Set(input.attachmentIds)); if (attachmentIds.length > 0) { effects.push({ diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 3c3094f3aa60..99f89f3544c3 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -54,6 +54,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly awaitAttached: (key: DesktopTabKey, timeout: Duration.Input) => Effect.Effect; /** Desktop tabs as they detach. */ readonly detached: Stream.Stream; + /** Desktop tabs as they attach, including a tab coming back after its DevTools close. */ + readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; /** * A one-connection CDP endpoint for an attached tab. Closing the scope @@ -83,6 +85,7 @@ const make = Effect.gen(function* () { available: false, awaitAttached: () => Effect.succeed(false), detached: Stream.empty, + attached: Stream.empty, isAttached: () => Effect.succeed(false), endpoint: () => Effect.die("No desktop app is attached to this server."), pointer: () => Effect.void, @@ -216,6 +219,10 @@ const make = Effect.gen(function* () { Stream.filter((change) => !change.attached), Stream.map((change) => change.key), ), + attached: Stream.fromPubSub(changes).pipe( + Stream.filter((change) => change.attached), + Stream.map((change) => change.key), + ), isAttached: (key) => Effect.sync(() => attachedTabs.has(keyOf(key))), endpoint, pointer: (key, pointer) => command({ type: "pointer", ...key, ...pointer }), diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 7ca109725485..9420e07a5a3a 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -317,6 +317,9 @@ export const make = Effect.gen(function* PreviewManagerMake() { title: input.navStatus.title, code: input.navStatus.code, description: input.navStatus.description, + ...(input.navStatus.download === undefined + ? {} + : { download: input.navStatus.download }), } : { type: "navigated", diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 46d3750e9335..fa4fd4402e93 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; @@ -53,13 +54,18 @@ const layerTestIntegrationNet = Layer.succeed(Net.NetService, { findAvailablePort: (preferred) => Effect.succeed(preferred), }); +/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +const layerNoProc = FileSystem.layerNoop({}); + const layerProbeFailure = ( run: ProcessRunner.ProcessRunner["Service"]["run"], fetch: typeof globalThis.fetch = globalThis.fetch, + fileSystem: Layer.Layer = layerNoProc, ) => PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), @@ -77,6 +83,7 @@ const layerProbeFailure = ( const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + layerNoProc, layerTestProcessRunner, layerTestIntegrationNet, Layer.succeed(HostProcessPlatform, "win32"), @@ -94,6 +101,7 @@ const layerLsofScanner = (input: { PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ @@ -671,6 +679,112 @@ effectIt.effect("does not swallow process probe defects", () => }), ); +// /proc/net/tcp: 127.0.0.1:8765 and 0.0.0.0:22 listening, an established +// connection, and a non-loopback listener; /proc/net/tcp6 adds [::]:3001. +const PROC_NET_TCP = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:223D 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5001 1 0000000000000000 100 0 0 10 0 + 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 5002 1 0000000000000000 100 0 0 10 0 + 2: 0100007F:223D 0100007F:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5003 1 0000000000000000 20 4 30 10 -1 + 3: 0A00000F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5005 1 0000000000000000 100 0 0 10 0 +`; +const PROC_NET_TCP6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000000000000:0BB9 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5004 1 0000000000000000 100 0 0 10 0 +`; + +effectIt.effect( + "reads Linux listeners from /proc when lsof is missing, and stops spawning it", + () => { + let lsofSpawns = 0; + const fdWalks: string[] = []; + const procFiles: Record = { + "/proc/net/tcp": PROC_NET_TCP, + "/proc/net/tcp6": PROC_NET_TCP6, + "/proc/4242/comm": "python3\n", + }; + const procFds: Record> = { + "4242": { "0": "/dev/null", "3": "socket:[5001]" }, + "77": { "5": "socket:[9999]" }, + }; + const missing = FileSystem.makeNoop({}); + const fileSystem = FileSystem.layerNoop({ + readFileString: (path) => { + const content = procFiles[path]; + return content === undefined ? missing.readFileString(path) : Effect.succeed(content); + }, + readDirectory: (path) => { + if (path === "/proc") return Effect.succeed(["self", "77", "4242"]); + const pid = /^\/proc\/(\d+)\/fd$/.exec(path)?.[1]; + const fds = pid === undefined ? undefined : procFds[pid]; + if (pid === undefined || fds === undefined) return missing.readDirectory(path); + fdWalks.push(pid); + return Effect.succeed(Object.keys(fds)); + }, + readLink: (path) => { + const [, pid, fd] = /^\/proc\/(\d+)\/fd\/(\d+)$/.exec(path) ?? []; + const target = pid && fd ? procFds[pid]?.[fd] : undefined; + return target === undefined ? missing.readLink(path) : Effect.succeed(target); + }, + }); + const fetchFn = (() => + Promise.resolve( + new Response("hello", { headers: { "content-type": "text/html" } }), + )) as typeof globalThis.fetch; + const layer = layerProbeFailure( + (input) => { + lsofSpawns += 1; + return processProbeFailure(input); + }, + fetchFn, + fileSystem, + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const first = yield* scanner.scan(); + expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ + { port: 22, pid: null, processName: null }, + { port: 3001, pid: null, processName: null }, + { port: 8765, pid: 4242, processName: "python3" }, + ]); + yield* scanner.scan(); + expect(lsofSpawns).toBe(1); + // Ports 22 and 3001 have no readable owner, so the second scan walks + // again; it stops at 77, which holds no listener, once nothing is left. + expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); + yield* scanner.scan(); + yield* scanner.scan(); + // Unresolved owners are retried a bounded number of times. + expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("keeps spawning lsof after a spawn failure that is not a missing command", () => { + let lsofSpawns = 0; + const layer = layerProbeFailure((input) => { + lsofSpawns += 1; + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cwd: input.cwd, + cause: PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "EAGAIN", + }), + }), + ); + }); + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.scan(); + yield* scanner.scan(); + expect(lsofSpawns).toBe(2); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect("does not swallow process probe interruption", () => Effect.gen(function* () { const layer = layerProbeFailure(() => Effect.interrupt); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 6fb25077df3a..eb573def97e0 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,8 +5,11 @@ * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * - * Windows / lsof missing: checks a curated list of common dev ports through - * the shared Net service. + * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and + * finds their processes through `/proc//fd`. + * + * Windows listener failures, or neither source: checks a curated list of + * common dev ports through the shared Net service. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -30,8 +33,10 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; @@ -72,6 +77,10 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; +/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ +const PROC_FD_WALK_LIMIT = 50_000; +/** Scans that retry the fd walk for a listening socket whose owner was not found. */ +const SOCKET_OWNER_ATTEMPTS = 3; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); @@ -265,6 +274,42 @@ const parseWindowsListenerOutput = ( return [...seen.values()].toSorted((left, right) => left.port - right.port); }; +/** `/proc/net/tcp` state for a listening socket. */ +const PROC_TCP_LISTEN = "0A"; + +/** + * Whether a `/proc/net/tcp{,6}` address is one the preview can reach on + * loopback: the wildcard or a loopback address, as `lsof` parsing accepts. + * Each 32-bit word of the hex address is little-endian. + */ +const procLocalAddress = (hex: string): boolean => { + if (hex.length === 8) { + return hex === "00000000" || hex.endsWith("7F"); + } + if (hex.length !== 32) return false; + if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true; + // IPv4-mapped (::ffff:a.b.c.d): wildcard or 127.0.0.0/8. + return hex.startsWith(`${"0".repeat(16)}FFFF0000`) && procLocalAddress(hex.slice(24)); +}; + +/** Listening loopback ports in `/proc/net/tcp{,6}` content, with their socket inodes. */ +const parseProcNetTcp = ( + raw: string, +): ReadonlyArray<{ readonly port: number; readonly inode: string }> => { + const listeners: Array<{ port: number; inode: string }> = []; + for (const line of raw.split("\n").slice(1)) { + const fields = line.trim().split(/\s+/); + const [address, portHex] = fields[1]?.split(":") ?? []; + if (fields[3] !== PROC_TCP_LISTEN || !address || !portHex) continue; + if (!procLocalAddress(address.toUpperCase())) continue; + const port = Number.parseInt(portHex, 16); + const inode = fields[9]; + if (!Number.isInteger(port) || port <= 0 || port >= 65536 || !inode) continue; + listeners.push({ port, inode }); + } + return listeners; +}; + const serversEqual = ( left: ReadonlyArray, right: ReadonlyArray, @@ -289,10 +334,14 @@ const serversEqual = ( return true; }; +const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => + PlatformError.isPlatformError(error.cause) && error.cause.reason._tag === "NotFound"; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ @@ -302,6 +351,101 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }); const webProbeCacheRef = yield* Ref.make>(new Map()); const scanSemaphore = yield* Semaphore.make(1); + const lsofMissingRef = yield* Ref.make(false); + /** Confirmed owners of listening socket inodes. */ + const socketOwnersRef = yield* Ref.make< + ReadonlyMap + >(new Map()); + /** Fd walks that found no owner, per socket inode; retried up to `SOCKET_OWNER_ATTEMPTS`. */ + const socketOwnerMissesRef = yield* Ref.make>(new Map()); + + /** Maps socket inodes to the processes holding them, by `/proc//fd`. */ + const findSocketOwners = Effect.fn("PortDiscovery.findSocketOwners")(function* ( + inodes: ReadonlySet, + ) { + const owners = new Map(); + const pids = (yield* fileSystem + .readDirectory("/proc") + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).filter((entry) => + /^\d+$/.test(entry), + ); + let budget = PROC_FD_WALK_LIMIT; + for (const pidText of pids) { + if (owners.size === inodes.size || budget <= 0) break; + // Other users' fds are unreadable; their ports still list, without a pid. + const fds = yield* fileSystem + .readDirectory(`/proc/${pidText}/fd`) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const walked = fds.slice(0, budget); + budget -= walked.length; + const links = yield* Effect.forEach( + walked, + (fd) => + fileSystem.readLink(`/proc/${pidText}/fd/${fd}`).pipe(Effect.orElseSucceed(() => "")), + { concurrency: 16 }, + ); + const held = links.flatMap((link) => /^socket:\[(\d+)\]$/.exec(link)?.[1] ?? []); + const matched = held.filter((inode) => inodes.has(inode) && !owners.has(inode)); + if (matched.length === 0) continue; + const processName = yield* fileSystem.readFileString(`/proc/${pidText}/comm`).pipe( + Effect.map((name) => name.trim() || null), + Effect.orElseSucceed(() => null), + ); + for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName }); + } + return owners; + }); + + /** + * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null + * when neither file is readable, so discovery falls back to common ports. + */ + const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( + terminalByProcessId: ReadonlyMap, + ) { + if (hostPlatform !== "linux") return null; + const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) => + fileSystem.readFileString(path).pipe(Effect.option), + ); + if (tables.every(Option.isNone)) return null; + const listeners = tables.flatMap((table) => + Option.isSome(table) ? parseProcNetTcp(table.value) : [], + ); + const known = yield* Ref.get(socketOwnersRef); + const knownMisses = yield* Ref.get(socketOwnerMissesRef); + const inodes = new Set(listeners.map((listener) => listener.inode)); + // Listeners rarely change, so the fd walk runs only for sockets without a + // confirmed owner, and gives up on one after a few walks miss it (another + // user's process, or one that exited mid-walk). + const unseen = new Set( + [...inodes].filter( + (inode) => !known.has(inode) && (knownMisses.get(inode) ?? 0) < SOCKET_OWNER_ATTEMPTS, + ), + ); + const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen); + const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode))); + const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode))); + for (const inode of unseen) { + if (!found.has(inode)) misses.set(inode, (misses.get(inode) ?? 0) + 1); + } + yield* Ref.set(socketOwnersRef, owners); + yield* Ref.set(socketOwnerMissesRef, misses); + const seen = new Map(); + for (const { port, inode } of listeners) { + const owner = owners.get(inode) ?? null; + const existing = seen.get(port); + if (existing && (existing.pid !== null || owner === null)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: owner?.processName ?? null, + pid: owner?.pid ?? null, + terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); + }); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { const results = yield* Effect.forEach( @@ -514,6 +658,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); + if (yield* Ref.get(lsofMissingRef)) { + const fromProc = yield* scanProcListeners(terminalByProcessId); + return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + } const lsofResult = yield* processRunner .run({ command: "lsof", @@ -525,7 +673,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), Effect.catchTags({ - ProcessSpawnError: recoverLsofProbeFailure, + // A missing lsof stays missing; later scans skip straight to the fallback. + ProcessSpawnError: (error) => + (isCommandNotFound(error) ? Ref.set(lsofMissingRef, true) : Effect.void).pipe( + Effect.andThen(recoverLsofProbeFailure(error)), + ), ProcessStdinError: recoverLsofProbeFailure, ProcessOutputLimitError: recoverLsofProbeFailure, ProcessReadError: recoverLsofProbeFailure, @@ -533,7 +685,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + const fromProc = yield* scanProcListeners(terminalByProcessId); + return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( diff --git a/apps/server/src/preview/PreviewBrowserHost.test.ts b/apps/server/src/preview/PreviewBrowserHost.test.ts index 4c42e37ebb71..8fcfddff5cb5 100644 --- a/apps/server/src/preview/PreviewBrowserHost.test.ts +++ b/apps/server/src/preview/PreviewBrowserHost.test.ts @@ -20,7 +20,11 @@ const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) const SANDBOX_ABORT = "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/"; -const lddReporting = (stdout: string) => { +// What the real loader prints when the browser's `--version` cannot start it. +const LOADER_ERROR = + "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; + +const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { const commands: Array = []; const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { @@ -36,9 +40,9 @@ const lddReporting = (stdout: string) => { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(stdout)), + stdout: Stream.empty, stderr: Stream.empty, - all: Stream.empty, + all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)), getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); @@ -47,8 +51,16 @@ const lddReporting = (stdout: string) => { return { spawner, commands }; }; -const diagnose = (input: { platform: NodeJS.Platform; output: string; ldd: string }) => { - const { spawner, commands } = lddReporting(input.ldd); +const diagnose = (input: { + platform: NodeJS.Platform; + output: string; + ldd: string; + version?: string; +}) => { + const { spawner, commands } = hostReporting({ + ldd: input.ldd, + version: input.version ?? LOADER_ERROR, + }); return PreviewBrowserHost.diagnoseLaunchFailure({ executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", output: input.output, @@ -89,12 +101,26 @@ describe("diagnoseLaunchFailure", () => { }), ); + it.effect("trusts the browser's own start over ldd", () => + Effect.gen(function* () { + // NixOS: ldd lists libraries that nix-ld still provides to the real loader. + const { error } = yield* diagnose({ + platform: "linux", + output: "crashed", + ldd: LDD_IN_BARE_DEBIAN, + version: "Google Chrome for Testing 154.0.8037.92\n", + }); + expect(error).toBeUndefined(); + }), + ); + it.effect("leaves other failures alone", () => Effect.gen(function* () { const linux = yield* diagnose({ platform: "linux", output: "crashed", ldd: "\tlibc.so.6 => /lib/libc.so.6\n", + version: "Google Chrome for Testing 154.0.8037.92\n", }); expect(linux.error).toBeUndefined(); const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); diff --git a/apps/server/src/preview/PreviewBrowserHost.ts b/apps/server/src/preview/PreviewBrowserHost.ts index 0b5d0d204a97..9ea56c4fa18b 100644 --- a/apps/server/src/preview/PreviewBrowserHost.ts +++ b/apps/server/src/preview/PreviewBrowserHost.ts @@ -90,11 +90,13 @@ export class PreviewBrowserLibrariesError extends Schema.TaggedError not found$/gm; +/** glibc's loader abort, naming the first library it could not load. */ +const LOADER_ERROR = /error while loading shared libraries: ([^:\s]+)/; /** * After a launch fails, names the host setup it is missing: the sandbox, from - * Chrome's own abort message, or shared libraries, from `ldd`. Undefined when - * neither explains it. Linux only; other hosts never need either. + * Chrome's own abort message, or shared libraries the loader refuses. Undefined + * when neither explains it. Linux only; other hosts never need either. */ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunchFailure")( function* (input: { @@ -114,18 +116,29 @@ export const diagnoseLaunchFailure = Effect.fn("PreviewBrowserHost.diagnoseLaunc }, ); -/** Shared libraries the loader cannot find for `executable`, by `ldd`. */ +/** + * Shared libraries the loader cannot find for `executable`. The browser's own + * `--version` decides whether any are missing; `ldd` only lists them after the + * real loader fails, because it can report libraries a host provides another + * way (NixOS's nix-ld). + */ export const missingLibraries = Effect.fn("PreviewBrowserHost.missingLibraries")(function* ( executable: string, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const report = yield* spawner - .string(ChildProcess.make("ldd", [executable], { stdin: "ignore", stderr: "ignore" })) - .pipe( - Effect.timeout("5 seconds"), - Effect.orElseSucceed(() => ""), - ); - return [...report.matchAll(MISSING_LIBRARY)].map((match) => match[1]!); + const run = (command: string, args: ReadonlyArray) => + spawner + .string(ChildProcess.make(command, args, { stdin: "ignore" }), { includeStderr: true }) + .pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => ""), + ); + const loaderError = LOADER_ERROR.exec(yield* run(executable, ["--version"]))?.[1]; + if (loaderError === undefined) return []; + const listed = [...(yield* run("ldd", [executable])).matchAll(MISSING_LIBRARY)].map( + (match) => match[1]!, + ); + return listed.length > 0 ? listed : [loaderError]; }); /** diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index dce0c23b96cf..45a2167ca042 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import type { BrowserContext, Page } from "playwright-core"; @@ -31,6 +32,7 @@ import * as PreviewBrowser from "./PreviewBrowser.ts"; // Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only. vi.mock("./ServerBrowserContexts.ts", () => ({ + presentAsChrome: async () => {}, ServerBrowserContexts: class { private readonly onClose: ((context: BrowserContext) => void) | undefined; constructor(options: { onContextClose?: (context: BrowserContext) => void }) { @@ -153,7 +155,7 @@ let clipboardBinding: ClipboardBinding | null = null; let contextFailure: Error | null = null; /** Server tabs the fake desktop renders, and the endpoints the server connected to. */ let desktopRendersNext = false; -/** Pages the fake desktop takes back; the channel's detached stream emits them. */ +/** Pages the fake desktop takes back or returns; the channel's streams emit them. */ const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { @@ -209,6 +211,17 @@ const dependencies = Layer.mergeAll( (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), ), ), + attached: Stream.callback<{ threadId: string; tabId: string }>((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onAttach = (key: { threadId: string; tabId: string }) => + Queue.offerUnsafe(queue, key); + desktopDetaches.on("attach", onAttach); + return onAttach; + }), + (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)), + ), + ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => @@ -726,6 +739,225 @@ it.live("a popup becomes the agent's own tab and keeps its opener page", () => ).pipe(Effect.provide(layer)), ); +it.live("a tab opened to a file the browser cannot show reports the file to download", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true }; + // What Chromium reports when a navigation turns into a download. + page.emit("requestfailed", { + ...request, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }); + // Stands in for Chromium, which writes the file itself. + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + written.resolve(); + let status = yield* PubSub.take(events); + while (status.type !== "failed") status = yield* PubSub.take(events); + expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } }); + // The tab shows the file, so no separate download toast is offered. + expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe( + false, + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const navigation = { + url: () => pdf, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + page.emit("request", navigation); + page.emit("requestfailed", navigation); + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + // The person navigates elsewhere while the file is still being written. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + written.resolve(); + let offered = yield* Queue.take(viewer.output); + while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); + expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a download from a superseded navigation leaves the newer navigation loading", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const pdf = "https://example.com/paper.pdf"; + const navigation = { + url: () => pdf, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + page.emit("request", navigation); + page.emit("requestfailed", navigation); + const saved = yield* Queue.unbounded(); + const written = Promise.withResolvers(); + page.emit("download", { + failure: async () => null, + saveAs: (path: string) => { + Queue.offerUnsafe(saved, path); + return written.promise; + }, + suggestedFilename: () => "paper.pdf", + url: () => pdf, + }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* Queue.take(saved), "%PDF"); + // The person navigates elsewhere while the file is still being written. + page.emit("request", { + url: () => "https://example.com/next", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + }); + written.resolve(); + const status = () => + broker.invoke({ scope, tabId, operation: "status", input: {} }); + while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis"); + expect((yield* status()).loading).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live( + "a late failure from a request that predates tracking leaves a newer navigation loading", + () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const navigation = (url: string) => ({ + url: () => url, + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }), + }); + page.emit("request", navigation("https://example.com/next")); + // A popup's first request can start before the tab listens for requests. + page.emit("requestfailed", navigation("https://example.com/first")); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.loading).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an aborted navigation that no download explains stops loading", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const navigation = { + url: () => "https://example.com/cancelled", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_ABORTED" }), + }; + const status = () => + broker.invoke({ scope, tabId, operation: "status", input: {} }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + page.emit("request", navigation); + page.emit("requestfailed", navigation); + expect((yield* status()).loading).toBe(true); + vi.advanceTimersByTime(5_000); + } finally { + vi.useRealTimers(); + } + expect((yield* status()).loading).toBe(false); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a popup from a person's click is shown to that person, with its opener kept", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const opener = contexts[0]!.page; + const popup = makeContext(); + opener.emit("popup", popup.page); + let shown = yield* Queue.take(viewer.output); + while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output); + const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions; + expect(sessions.map((session) => session.tabId)).toEqual( + expect.arrayContaining([opened.tabId, shown.tabId]), + ); + expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe( + false, + ); + expect(opener.close).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("closing a tab while a viewer is still opening it does not leave its page behind", () => Effect.scoped( Effect.gen(function* () { @@ -1090,3 +1322,24 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( }), ).pipe(Effect.provide(layer)), ); + +it.live("a desktop page that comes back reconnects without waiting for a viewer", () => + Effect.scoped( + Effect.gen(function* () { + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + while (desktopConnections.length === 0) yield* Effect.yieldNow; + while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + // DevTools opened, then closed: the desktop withdraws the page and returns it. + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow; + desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients. + while (desktopConnections.length < 2) yield* Effect.yieldNow; + expect(desktopConnections).toHaveLength(2); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 42dc916abb61..7ca2e1ffba8e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -36,7 +36,11 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, type PreviewAppearancePreference, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import * as NodeCrypto from "node:crypto"; @@ -73,7 +77,7 @@ import * as PreviewManager from "./Manager.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; import * as PreviewBrowser from "./PreviewBrowser.ts"; import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; -import { ServerBrowserContexts } from "./ServerBrowserContexts.ts"; +import { presentAsChrome, ServerBrowserContexts } from "./ServerBrowserContexts.ts"; import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; const SERVER_HOST_CLIENT_ID = SERVER_BROWSER_AUTOMATION_CLIENT_ID; @@ -185,6 +189,8 @@ export type ServerBrowserViewerOutput = readonly accept: string; } | { readonly _tag: "fileChooserClosed"; readonly id: string } + /** The page this viewer controls opened a new tab; the viewer shows it, as a browser would. */ + | { readonly _tag: "popup"; readonly tabId: string } | { readonly _tag: "download"; readonly id: string; @@ -311,6 +317,17 @@ interface ServerTab { closing: boolean; recording: Recording | null; initialNavigation: Promise | null; + /** Counts main-frame navigation requests, so late events can tell they were superseded. */ + navigationGeneration: number; + /** + * The latest main-frame navigation, when it aborted, to tell a navigation's + * download from a page's. The timer settles it if no download follows. + */ + abortedNavigation: { + readonly url: string; + readonly generation: number; + readonly timer: ReturnType; + } | null; /** The latest queued start, so a stop can find a recording still starting. */ recordingStart: Promise | null; /** Serializes captures and recording start/stop. */ @@ -336,6 +353,8 @@ const AGENT_TAB_IDLE_MS = 30 * 60 * 1000; const IDLE_SWEEP_INTERVAL = "1 minute"; const DOWNLOAD_LIMIT = 20; +/** How long an aborted navigation waits for the download that usually explains it. */ +const ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS = 5_000; const DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024; const tabKey = (threadId: string, tabId: string) => `${threadId}\u0000${tabId}`; @@ -435,6 +454,7 @@ const CLIPBOARD_SCRIPT = `(() => { const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const host = { platform: yield* HostProcessPlatform, arch: yield* HostProcessArchitecture }; const manager = yield* PreviewManager.PreviewManager; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const environment = yield* ServerEnvironment.ServerEnvironment; @@ -646,6 +666,7 @@ const make = Effect.gen(function* () { if (tabs.get(key) !== tab) return; tabs.delete(key); tab.closing = true; + clearAbortedNavigation(tab); // A desktop page outlives the connection unless its session closed with it. const end = tab.desktop && !closeSession && sessionOpen(tab) ? "reconnect" : "gone"; for (const viewer of tab.viewers) viewer.push({ _tag: end }); @@ -723,6 +744,9 @@ const make = Effect.gen(function* () { if (!desktop) await prepareContext(context); const page = adopted?.page ?? desktop?.page ?? (await context.newPage()); const cdp = await context.newCDPSession(page); + // The desktop's page is already a normal browser. A popup's first request + // went out before it became a tab; everything after presents as Chrome. + if (!desktop) await presentAsChrome(cdp, host); page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS); const control = new SessionControl(snapshot.automationOwner ?? null, () => @@ -755,6 +779,8 @@ const make = Effect.gen(function* () { recording: null, recordingStart: null, initialNavigation: null, + navigationGeneration: 0, + abortedNavigation: null, captureLock: Promise.resolve(), capturing: 0, }; @@ -764,8 +790,11 @@ const make = Effect.gen(function* () { await applyRendering(tab, snapshot); const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); + const navigationGenerations = new WeakMap(); page.on("request", (request) => { if (!isMainNavigation(request)) return; + navigationGenerations.set(request, ++tab.navigationGeneration); + clearAbortedNavigation(tab); tab.loading = true; report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); }); @@ -787,7 +816,28 @@ const make = Effect.gen(function* () { errorText, timestamp: new Date().toISOString(), }); - if (!isMainNavigation(request) || errorText.includes("ERR_ABORTED")) return; + if (!isMainNavigation(request)) return; + // A navigation a newer one replaced leaves the status to the newer one. A + // popup's first request can predate the listener, so it is untracked and + // superseded by any tracked one. + const generation = navigationGenerations.get(request) ?? 0; + if (generation !== tab.navigationGeneration) return; + // An aborted navigation is usually a download; its `download` event settles the status. + if (errorText.includes("ERR_ABORTED")) { + const url = request.url(); + const navigationGeneration = tab.navigationGeneration; + clearAbortedNavigation(tab); + tab.abortedNavigation = { + url, + generation: navigationGeneration, + timer: setTimeout(() => { + if (tab.abortedNavigation?.generation !== navigationGeneration) return; + tab.abortedNavigation = null; + if (!tab.closing) settleAbortedNavigation(tab, navigationGeneration, url, undefined); + }, ABORTED_NAVIGATION_DOWNLOAD_WAIT_MS), + }; + return; + } tab.loading = false; const { code, description } = ServerBrowserPage.parseNetError(errorText); report(tab, { @@ -854,9 +904,46 @@ const make = Effect.gen(function* () { NodeCrypto.createHash("sha256").update(tabKey(tab.threadId, tab.tabId)).digest("hex"), ); + const clearAbortedNavigation = (tab: ServerTab) => { + if (tab.abortedNavigation !== null) clearTimeout(tab.abortedNavigation.timer); + tab.abortedNavigation = null; + }; + + /** + * Settles the status of a navigation that aborted, usually into a download. + * A tab opened straight to the file has no page to show, so it reports the + * file with its download; a page that linked to it keeps showing itself. + */ + const settleAbortedNavigation = ( + tab: ServerTab, + generation: number, + url: string, + offered: { readonly id: string; readonly fileName: string } | undefined, + ) => { + // A newer navigation owns the status. + if (tab.navigationGeneration !== generation) return; + tab.loading = false; + if (tab.page.url() !== "about:blank") { + void reportLoaded(tab); + return; + } + report(tab, { + _tag: "LoadFailed", + url: url.slice(0, 2048), + title: "", + code: -3, + description: "ERR_ABORTED", + ...(offered === undefined ? {} : { download: offered }), + }); + }; + const saveDownload = async (tab: ServerTab, download: Download) => { const id = NodeCrypto.randomUUID(); const path = NodePath.join(downloadDir(tab), id); + const navigation = + download.url() === tab.abortedNavigation?.url ? tab.abortedNavigation.generation : null; + if (navigation !== null) clearAbortedNavigation(tab); + let offered: { readonly id: string; readonly fileName: string } | undefined; try { if (await download.failure()) return; await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); @@ -878,6 +965,12 @@ const make = Effect.gen(function* () { for (const evicted of tab.downloads.splice(0, tab.downloads.length - DOWNLOAD_LIMIT)) { await NodeFSP.rm(evicted.path, { force: true }).catch(constVoid); } + offered = { id, fileName: saved.fileName }; + // A tab whose own address was the file shows it in place (see + // settleAbortedNavigation), unless it navigated on while the file saved. + const shownInTab = + navigation === tab.navigationGeneration && tab.page.url() === "about:blank"; + if (shownInTab) return; // Only the person driving the page gets the file offered; agents read it from status. const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); controller?.push({ @@ -889,6 +982,10 @@ const make = Effect.gen(function* () { } catch (cause) { await NodeFSP.rm(path, { force: true }).catch(constVoid); runFork(Effect.logWarning("server preview download failed", { cause })); + } finally { + if (navigation !== null && !tab.closing) { + settleAbortedNavigation(tab, navigation, download.url(), offered); + } } }; @@ -1009,6 +1106,10 @@ const make = Effect.gen(function* () { return; } const url = popup.url(); + // Captured now: the person whose click opened the popup gets switched to it. + const controller = [...opener.viewers].find( + (viewer) => viewer.id === opener.control.controller, + ); await Effect.runPromise( manager.open({ threadId: opener.threadId, @@ -1025,9 +1126,16 @@ const make = Effect.gen(function* () { openerTabId: opener.tabId, }), }), - ).catch(async () => { - await popup.close().catch(constVoid); - }); + ).then( + (snapshot) => { + // The opener stays open behind it, so `window.opener` can still report back. + if (opener.control.agentId === null) + controller?.push({ _tag: "popup", tabId: snapshot.tabId }); + }, + async () => { + await popup.close().catch(constVoid); + }, + ); }; const ensureTab = (snapshot: PreviewSessionSnapshot): Promise => { @@ -1187,6 +1295,12 @@ const make = Effect.gen(function* () { } await navigation.catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); + if (message.includes("Download is starting")) { + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationExecutionError", + `${url} is a file this browser cannot show, so it was downloaded instead. preview_status lists it under downloads.`, + ); + } if (/ERR_[A-Z_]+/.test(message)) { throw new ServerBrowserPage.ServerBrowserOperationError( "PreviewAutomationExecutionError", @@ -1693,7 +1807,11 @@ const make = Effect.gen(function* () { ServerBrowserPage.scroll(tab.page, input as PreviewAutomationScrollInput), ); case "evaluate": - return ServerBrowserPage.evaluate(tab.cdp, input as PreviewAutomationEvaluateInput); + return ServerBrowserPage.evaluate( + tab.cdp, + input as PreviewAutomationEvaluateInput, + request.timeoutMs, + ); case "waitFor": return ServerBrowserPage.waitFor(tab.page, input as PreviewAutomationWaitForInput); case "recordingStart": { @@ -2142,6 +2260,22 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); + // The page came back, for example after its DevTools closed. Reconnect now + // rather than on the next viewer or agent, or the tab's URL and title stop + // reaching every client in the meantime. + yield* desktopChannel.attached.pipe( + Stream.runForEach((key) => + Effect.gen(function* () { + if (tabs.has(tabKey(key.threadId, key.tabId))) return; + const { sessions } = yield* manager.list({ threadId: ThreadId.make(key.threadId) }); + const snapshot = sessions.find( + (session) => session.tabId === key.tabId && session.runtime === "server", + ); + if (snapshot) yield* Effect.promise(() => ensureTab(snapshot).catch(constVoid)); + }).pipe(Effect.ignore), + ), + Effect.forkScoped, + ); yield* Effect.sync(closeIdleAgentTabs).pipe( Effect.repeat(Schedule.spaced(IDLE_SWEEP_INTERVAL)), Effect.forkScoped, diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 3e48bfb6b88c..5f5c94b83071 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -3,8 +3,9 @@ import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -import type { Browser, BrowserContext } from "playwright-core"; +import type { Browser, BrowserContext, CDPSession } from "playwright-core"; import { sandboxDisabled } from "./PreviewBrowserHost.ts"; @@ -23,6 +24,61 @@ interface Options { readonly onContextClose?: (context: BrowserContext) => void; } +const UA_PLATFORMS: Partial> = { + darwin: "macOS", + linux: "Linux", + win32: "Windows", +}; + +/** + * The headless shell's identity as the Chrome it is built from: its user agent + * without `HeadlessChrome`, and client hints whose brands agree with it. Sites + * block a user agent that disagrees with `Sec-CH-UA`, so both change together. + */ +const chromeIdentity = ( + headlessUserAgent: string, + host: { readonly platform: NodeJS.Platform; readonly arch: string; readonly release: string }, +) => { + const version = /HeadlessChrome\/((\d+)[\d.]*)/.exec(headlessUserAgent); + const fullVersion = version?.[1]; + const major = version?.[2]; + if (!version || !fullVersion || !major) return null; + // Chrome's GREASE brand and order for this major version, as the shell reports them. + const brands = (names: (version: string) => string) => [ + { brand: "Chromium", version: names(major) }, + { brand: "Google Chrome", version: names(major) }, + { brand: "Not A(Brand", version: names("99") }, + ]; + return { + // Chrome reduces its user agent to the major version. + userAgent: headlessUserAgent.replace(version[0], `Chrome/${major}.0.0.0`), + userAgentMetadata: { + brands: brands((value) => value), + fullVersionList: brands((value) => (value === major ? fullVersion : `${value}.0.0.0`)), + platform: UA_PLATFORMS[host.platform] ?? "Unknown", + platformVersion: host.platform === "linux" ? host.release : "", + architecture: host.arch.startsWith("arm") ? "arm" : "x86", + bitness: host.arch.endsWith("64") ? "64" : "32", + model: "", + mobile: false, + wow64: false, + }, + }; +}; + +/** + * Presents a headless page as plain Chrome. Applies to the page, its frames, + * and its workers; a popup gets its own call once it becomes a tab. + */ +export const presentAsChrome = async ( + cdp: CDPSession, + host: { readonly platform: NodeJS.Platform; readonly arch: string }, +) => { + const { userAgent } = await cdp.send("Browser.getVersion"); + const identity = chromeIdentity(userAgent, { ...host, release: NodeOS.release() }); + if (identity) await cdp.send("Emulation.setUserAgentOverride", identity); +}; + /** Persistent human profiles keep their storage; isolated agents share a browser, never a context. */ export class ServerBrowserContexts { private readonly options: Options; @@ -42,7 +98,13 @@ export class ServerBrowserContexts { return { executablePath, env, - args: ["--disable-gpu", "--force-device-scale-factor=2"], + // People drive these tabs, so they must not announce automation (navigator.webdriver). + args: [ + "--disable-gpu", + "--force-device-scale-factor=2", + "--disable-blink-features=AutomationControlled", + ], + ignoreDefaultArgs: ["--enable-automation"], headless: true, // Only an explicit operator opt-out disables sandboxing. Launch errors never do. chromiumSandbox: !sandboxDisabled(env), diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 3ab9d6dc35b8..291f89e125eb 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -7,6 +7,7 @@ import { } from "playwright-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { presentAsChrome } from "./ServerBrowserContexts.ts"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; describe("server browser element refs", () => { @@ -245,4 +246,34 @@ describe("server browser element refs", () => { ]); expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); }); + + it("presents a headless page as Chrome, with client hints that agree", async () => { + await presentAsChrome(cdp, { platform: "linux", arch: "x64" }); + // userAgentData exists only in secure contexts; https comes from a route. + let secChUa: string | undefined; + await page.route("https://example.test/", (route) => { + secChUa = route.request().headers()["sec-ch-ua"]; + return route.fulfill({ contentType: "text/html", body: "

hi

" }); + }); + await page.goto("https://example.test/"); + const identity = (await page.evaluate(`(async () => ({ + userAgent: navigator.userAgent, + brands: navigator.userAgentData.brands.map((brand) => brand.brand), + full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"])) + .fullVersionList.map((brand) => brand.brand), + }))()`)) as { userAgent: string; brands: string[]; full: string[] }; + expect(identity.userAgent).not.toContain("Headless"); + expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /); + expect(identity.brands).toContain("Google Chrome"); + expect([...identity.brands, ...identity.full].join()).not.toContain("Headless"); + expect(secChUa).toContain('"Google Chrome"'); + expect(secChUa).not.toContain("Headless"); + }); + + it("stops an evaluation at its deadline so the page answers the next one", async () => { + await expect( + ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200), + ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); + }); }); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index b6a397e293a6..4c2a36478e07 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -410,12 +410,36 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; -export const evaluate = async (cdp: CDPSession, input: PreviewAutomationEvaluateInput) => { - const result = await cdp.send("Runtime.evaluate", { +/** + * Runs an agent's expression, bounded by its request's deadline. Past it, the + * page's script is terminated and the call fails, so the tab's control queue + * frees for the next request instead of waiting on the script. + */ +export const evaluate = async ( + cdp: CDPSession, + input: PreviewAutomationEvaluateInput, + timeoutMs: number, +) => { + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + // Stops a busy script; an awaited promise is abandoned and settles unread. + void cdp.send("Runtime.terminateExecution").catch(constVoid); + reject( + new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + `Evaluation did not finish within ${timeoutMs}ms and was stopped.`, + ), + ); + }, timeoutMs); + }); + const evaluation = cdp.send("Runtime.evaluate", { expression: input.expression, awaitPromise: input.awaitPromise ?? true, returnByValue: input.returnByValue ?? true, }); + void evaluation.catch(constVoid); + const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer)); if (result.exceptionDetails) { throw new ServerBrowserOperationError( "PreviewAutomationExecutionError", diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index cfb124c4f9eb..964f143f5ef6 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -156,6 +156,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => case "download": case "fileChooser": case "fileChooserClosed": + case "popup": case "pointer": case "probe": { const { _tag: type, ...data } = output; diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 746c139f6fc8..a64896b637f8 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -197,9 +197,13 @@ it.effect("retries a partial project deletion without repeating child events or assert.lengthOf(partialEvents, 1); assert.equal(partialEvents[0]?.stream_id, firstThreadId); assert.equal(partialEvents[0]?.event_type, "thread.deleted"); - assert.lengthOf(partialCleanup, 1); - assert.equal(partialCleanup[0]?.thread_id, firstThreadId); - assert.equal(partialCleanup[0]?.effect_type, "terminal.cleanup"); + assert.deepEqual( + partialCleanup.map((effect) => [effect.thread_id, effect.effect_type]), + [ + [firstThreadId, "preview.cleanup"], + [firstThreadId, "terminal.cleanup"], + ], + ); const deletedProject = yield* service.delete(input); assert.isNotNull(deletedProject.deletedAt); @@ -220,7 +224,7 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual(finalEvents[0], partialEvents[0]); assert.equal(finalEvents[2]?.command_id, commandId); const finalCleanup = yield* readCleanup; - assert.lengthOf(finalCleanup, 2); + assert.lengthOf(finalCleanup, 4); assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === firstThreadId), partialCleanup, @@ -230,6 +234,12 @@ it.effect("retries a partial project deletion without repeating child events or assert.deepEqual( finalCleanup.filter((effect) => effect.thread_id === threadId), [ + { + effect_id: `effect:${expectedCommandId}:preview.cleanup`, + thread_id: threadId, + command_id: expectedCommandId, + effect_type: "preview.cleanup", + }, { effect_id: `effect:${expectedCommandId}:terminal.cleanup`, thread_id: threadId, diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 26013d720bc5..feca1cbaf794 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -153,6 +153,8 @@ export function ServerBrowserSurface(props: { readonly onFirstFrame?: () => void; readonly onViewport?: (viewport: PreviewStreamViewport) => void; readonly onControl?: (control: PreviewStreamControl | null) => void; + /** The page opened a new tab from this viewer's click; the viewer should switch to it. */ + readonly onPopup?: (tabId: string) => void; readonly className?: string; readonly ref?: Ref; }) { @@ -166,6 +168,7 @@ export function ServerBrowserSurface(props: { onFirstFrame, onViewport, onControl, + onPopup, className, ref, } = props; @@ -218,6 +221,7 @@ export function ServerBrowserSurface(props: { onViewport?.(viewport), ); const controlChanged = useEffectEvent((next: PreviewStreamControl | null) => onControl?.(next)); + const popupOpened = useEffectEvent((popupTabId: string) => onPopup?.(popupTabId)); // Frame cap in device px, fixed per socket. It grows with the surface and // never shrinks, so only outgrowing it reconnects. const [cap, setCap] = useState<{ width: number; height: number } | null>(null); @@ -397,6 +401,7 @@ export function ServerBrowserSurface(props: { }, onClipboard: (text) => void copyPageText(text), onDownload: offerDownload, + onPopup: popupOpened, onFileChooser: setFileChooser, onViewport: (viewport) => { viewportRef.current = viewport; diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index 557017838373..f44a33e931d5 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -13,6 +13,50 @@ interface Props { onReload: () => void; } +/** + * A server tab's address was a file its browser cannot show (a PDF), so the + * browser downloaded it. Offers the address in the user's own browser, or the + * file the server already saved. + */ +export function PreviewFileNotShown({ + url, + fileName, + downloadUrl, + onOpen, +}: { + url: string; + fileName: string; + downloadUrl: string | null; + onOpen: () => void; +}) { + const host = safeHost(url) ?? url; + return ( +
+
+ +

+ This file can’t be shown here +

+

+ {fileName} from {host} was + downloaded instead. +

+
+
+ + {downloadUrl ? ( + + ) : null} +
+
+
+ ); +} + /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ export function PreviewUnreachable({ url, code, description, onReload }: Props) { const [showDetails, setShowDetails] = useState(false); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index af5253448655..44a261ac6d78 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,6 +1,7 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -16,7 +17,7 @@ import { PREVIEW_ZOOM_LEVELS, type PreviewAdjustInput, } from "@t3tools/contracts"; -import { normalizePreviewUrl } from "@t3tools/shared/preview"; +import { normalizePreviewUrl, resolveAddressBarInput } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -37,6 +38,7 @@ import { import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; +import { usePreviewStreamAccess } from "~/state/previewStream"; import { useAtomCommand } from "~/state/use-atom-command"; import { browserMiniPlayerSource, @@ -49,6 +51,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; +import { showPreviewPopup } from "./showPreviewPopup"; import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu, type PreviewMoreMenuActions } from "./PreviewMoreMenu"; @@ -61,7 +64,7 @@ import { BrowserDeviceToolbar } from "~/browser/BrowserDeviceToolbar"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT } from "~/browser/browserViewportLayout"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; -import { PreviewUnreachable } from "./PreviewUnreachable"; +import { PreviewFileNotShown, PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { Badge } from "~/components/ui/badge"; @@ -103,6 +106,31 @@ function previewProfileName( const localApi = typeof window === "undefined" ? null : ensureLocalApi(); +/** Resolves stream access only while a server tab shows a file it downloaded. */ +function ServerTabFileNotShown(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly url: string; + readonly download: { readonly id: string; readonly fileName: string }; +}) { + const access = usePreviewStreamAccess(props.threadRef.environmentId); + return ( + void localApi?.shell.openExternal(props.url).catch(() => undefined)} + /> + ); +} + /** * Single-tab preview surface: chrome row on top, one webview below, empty * state when no session exists for the thread. @@ -259,12 +287,12 @@ export function PreviewView({ const handleSubmitUrl = useCallback( async (next: string) => { try { - const normalized = normalizePreviewUrl(next); - if (await navigateToResolvedUrl(normalized)) { - recordVisitForThread(threadRef, normalized); + const resolved = resolveAddressBarInput(next); + if (await navigateToResolvedUrl(resolved)) { + recordVisitForThread(threadRef, resolved); } } catch { - // Server-side `failed` event renders the unreachable view. + // Only empty input or an unsupported scheme lands here; the bar keeps the text. } }, [navigateToResolvedUrl, threadRef], @@ -995,6 +1023,7 @@ export function PreviewView({ onControl={(control) => setServerControlledTabId(control?.controller === "you" ? runtimeTabId : null) } + onPopup={(popupTabId) => showPreviewPopup(threadRef, popupTabId, "panel")} // Stays connected under the empty state so a URL picked there reaches the page. className={cn( "absolute inset-0 h-full w-full", @@ -1043,7 +1072,16 @@ export function PreviewView({ controller={controller} /> ) : null} - {navStatus._tag === "LoadFailed" ? ( + {navStatus._tag === "LoadFailed" && navStatus.download ? ( +
+ +
+ ) : navStatus._tag === "LoadFailed" ? (
showPreviewPopup(threadRef, popupTabId, "floating")} className="size-full" />
diff --git a/apps/web/src/components/preview/showPreviewPopup.ts b/apps/web/src/components/preview/showPreviewPopup.ts new file mode 100644 index 000000000000..56846c28bda1 --- /dev/null +++ b/apps/web/src/components/preview/showPreviewPopup.ts @@ -0,0 +1,24 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { whenPreviewTabKnown } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +/** + * Switches the viewer to a tab the page they drive just opened, as a browser + * does for a link click or `window.open`. A floating opener floats the popup; + * otherwise it becomes the panel's active tab. The opener stays open. + */ +export function showPreviewPopup( + threadRef: ScopedThreadRef, + popupTabId: string, + from: "panel" | "floating", +): void { + whenPreviewTabKnown(threadRef, popupTabId, () => { + if (from === "floating") { + usePreviewMiniPlayerStore.getState().open(threadRef, browserMiniPlayerSource(popupTabId)); + } else { + useRightPanelStore.getState().openBrowser(threadRef, popupTabId); + } + }); +} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index cab4dde3dd20..b0093f4a0a12 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -50,6 +50,7 @@ import { readThreadShells, } from "../state/entities"; import { useUiStateStore } from "../uiStateStore"; +import { clearThreadPreviewState } from "../previewStateStore"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -449,6 +450,7 @@ export function useThreadActions() { }); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); + clearThreadPreviewState(target); } return result; } @@ -555,6 +557,7 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); + clearThreadPreviewState(threadRef); if (shouldNavigateToFallback) { const fallbackThread = fallbackThreadId diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 13ce14a26dfa..f257a8e38c61 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -214,6 +214,7 @@ export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEven title: event.title, code: event.code, description: event.description, + ...(event.download === undefined ? {} : { download: event.download }), }, updatedAt: event.createdAt, }; @@ -457,6 +458,35 @@ export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void { }); } +/** + * Runs `action` once the thread's preview state has the tab, which a popup's + * `opened` event may deliver after the stream that announced it. Gives up + * after `timeoutMs`. Returns a cancel function. + */ +export function whenPreviewTabKnown( + ref: ScopedThreadRef, + tabId: string, + action: () => void, + timeoutMs = 5_000, +): () => void { + const atom = previewStateAtom(scopedThreadKey(ref)); + if (appAtomRegistry.get(atom).sessions[tabId]) { + action(); + return () => {}; + } + const stop = () => { + clearTimeout(timer); + unsubscribe(); + }; + const unsubscribe = appAtomRegistry.subscribe(atom, (state) => { + if (!state.sessions[tabId]) return; + stop(); + action(); + }); + const timer = setTimeout(stop, timeoutMs); + return stop; +} + export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { if (url.trim().length === 0) return; updateThreadPreviewState(ref, (current) => ({ @@ -470,6 +500,16 @@ export function isPreviewSupportedInRuntime(): boolean { return Boolean(window.desktopBridge?.preview); } +/** + * Forgets a deleted thread's previews. The server closes their sessions too, + * but the desktop host keeps a page for every session held here. + */ +export function clearThreadPreviewState(ref: ScopedThreadRef): void { + updateThreadPreviewState(ref, (current) => + Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE, + ); +} + export function resetPreviewStateForTests(): void { for (const threadKey of changedPreviewThreadKeys) { appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 7bfcd9b42cfc..34a3786976d0 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -139,7 +139,8 @@ export async function uploadPreviewStreamFiles( if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); } -const previewStreamDownloadUrl = ( +/** A file a server tab downloaded, served with the stream's own access. */ +export const previewStreamDownloadUrl = ( target: Pick, id: string, ): string => @@ -202,6 +203,8 @@ export interface PreviewStreamEvents { readonly onClipboard?: (text: string) => void; /** A file the page downloaded while this viewer had control. */ readonly onDownload?: (download: PreviewStreamDownload) => void; + /** The page this viewer controls opened a new tab (a link or `window.open`). */ + readonly onPopup?: (tabId: string) => void; /** The page opened a file picker (`null` once answered or replaced). */ readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; /** Input sent while disconnected is dropped. */ @@ -286,6 +289,7 @@ export function createPreviewStreamClient( accept, phase, sequence, + tabId, } = message as Record; if ( type === "fileChooser" && @@ -312,6 +316,8 @@ export function createPreviewStreamClient( typeof sequence === "number" ) { events.onPointer?.({ phase, x, y, sequence }); + } else if (type === "popup" && typeof tabId === "string") { + events.onPopup?.(tabId); } else if (type === "clipboard" && typeof text === "string") { events.onClipboard?.(text); } else if ( diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index e3e964192471..e534fff06984 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -143,6 +143,9 @@ export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Typ export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; +/** A file a server tab downloaded, served by the tab's download route by this id. */ +const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); + export const PreviewNavStatus = Schema.Union([ Schema.TaggedStruct("Idle", {}), Schema.TaggedStruct("Loading", { @@ -158,6 +161,12 @@ export const PreviewNavStatus = Schema.Union([ title: Title, code: Schema.Int, description: Schema.String, + /** + * Set when the address was a file the browser downloaded instead of a page + * it could show (a PDF in a server tab). The server tab's download route + * serves it by this id. + */ + download: Schema.optional(PreviewFileDownload), }), ]); export type PreviewNavStatus = typeof PreviewNavStatus.Type; @@ -347,6 +356,7 @@ const PreviewFailedEvent = Schema.Struct({ title: Title, code: Schema.Int, description: Schema.String, + download: Schema.optional(PreviewFileDownload), }); const PreviewClosedEvent = Schema.Struct({ diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 14139216194e..e228b785927a 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -5,8 +5,58 @@ import { newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, + resolveAddressBarInput, } from "./preview.ts"; +describe("resolveAddressBarInput", () => { + it("opens what users type as addresses", () => { + expect(resolveAddressBarInput("localhost:5173")).toBe("http://localhost:5173/"); + expect(resolveAddressBarInput("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path"); + expect(resolveAddressBarInput("my-box.tailnet.ts.net")).toBe("https://my-box.tailnet.ts.net/"); + expect(resolveAddressBarInput("cnn.com")).toBe("https://cnn.com/"); + expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); + expect(resolveAddressBarInput(" https://example.com/a b ")).toBe("https://example.com/a%20b"); + expect(resolveAddressBarInput("example.com:8080")).toBe("https://example.com:8080/"); + expect(resolveAddressBarInput("[::1]:3000")).toBe("http://[::1]:3000/"); + expect(resolveAddressBarInput("192.168.1.5:3000")).toBe("https://192.168.1.5:3000/"); + }); + + it("searches for text that is not an address", () => { + expect(resolveAddressBarInput("weather")).toBe("https://duckduckgo.com/?q=weather"); + expect(resolveAddressBarInput("how to center a div")).toBe( + "https://duckduckgo.com/?q=how%20to%20center%20a%20div", + ); + expect(resolveAddressBarInput("what is cnn.com")).toBe( + "https://duckduckgo.com/?q=what%20is%20cnn.com", + ); + expect(resolveAddressBarInput("what is https://example.com")).toBe( + "https://duckduckgo.com/?q=what%20is%20https%3A%2F%2Fexample.com", + ); + expect(resolveAddressBarInput("what is 10:30")).toBe( + "https://duckduckgo.com/?q=what%20is%2010%3A30", + ); + expect(resolveAddressBarInput("note: buy milk")).toBe( + "https://duckduckgo.com/?q=note%3A%20buy%20milk", + ); + }); + + it("still rejects empty input and unsupported schemes", () => { + expect(() => resolveAddressBarInput(" ")).toThrow(PreviewUrlNormalizationError); + expect(() => resolveAddressBarInput("ftp://example.com")).toThrow(PreviewUrlNormalizationError); + expect(() => resolveAddressBarInput("mailto:alice@example.com")).toThrow( + PreviewUrlNormalizationError, + ); + expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow( + PreviewUrlNormalizationError, + ); + // A known scheme with a numeric payload is not a host and port. + for (const input of ["ftp:21", "tel:5551234"]) { + expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError); + } + expect(resolveAddressBarInput("devbox:8080")).toBe("https://devbox:8080/"); + }); +}); + describe("newPreviewTabId", () => { it("returns a unique tab id every call", () => { const a = newPreviewTabId(); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index c609a3b5db74..393fba0a35aa 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -57,6 +57,74 @@ function previewUrlProtocol(rawUrl: string): string | undefined { return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); } +const SEARCH_URL = "https://duckduckgo.com/?q="; +/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ +const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; +const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/; +/** + * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234` + * is rejected rather than read as host and port. + */ +const KNOWN_NON_WEB_SCHEMES: ReadonlySet = new Set([ + "about:", + "blob:", + "chrome:", + "data:", + "file:", + "ftp:", + "javascript:", + "mailto:", + "sms:", + "ssh:", + "tel:", + "view-source:", + "ws:", + "wss:", +]); + +/** + * Turns what a user typed in an address bar into the URL to open: an address + * as `normalizePreviewUrl` reads it, or else a web search for the text. Text + * is an address when it has a scheme, or no spaces and a host that has a dot, + * a port, or is localhost or an IP. Throws only for empty input or an + * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`). + */ +export function resolveAddressBarInput(rawInput: string): string { + const trimmed = rawInput.trim(); + if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0) + return normalizePreviewUrl(trimmed); + if (!/\s/.test(trimmed)) { + // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or + // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read + // `mailto:alice@example.com` as credentials on a bare host, so reject here. + const protocol = previewUrlProtocol(trimmed); + if ( + protocol !== undefined && + (KNOWN_NON_WEB_SCHEMES.has(protocol) || !BARE_HOST_PORT_PATTERN.test(trimmed)) + ) { + if (protocol !== "http:" && protocol !== "https:") { + throw new PreviewUrlNormalizationError({ + inputLength: rawInput.length, + reason: "unsupported-protocol", + protocol, + }); + } + return normalizePreviewUrl(trimmed); + } + const authority = trimmed.split(/[/?#]/, 1)[0] ?? ""; + const host = authority.replace(/^[^@]*@/, "").replace(/:\d+$/, ""); + const hasPort = host.length < authority.replace(/^[^@]*@/, "").length; + if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) { + try { + return normalizePreviewUrl(trimmed); + } catch { + // Not a usable address after all; search for it. + } + } + } + return `${SEARCH_URL}${encodeURIComponent(trimmed)}`; +} + /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. * From d9f7c0caed5cc0fe84cec97f2b6f002084542a4e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:38:01 -0700 Subject: [PATCH 032/377] fix(desktop): browser tab fixes for fullscreen, shortcuts, links, reload and hidden tabs (#16961) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/package.json | 2 +- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/backend/DesktopBackendPool.test.ts | 1 + apps/desktop/src/electron/ElectronDialog.ts | 9 +- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 3 + apps/desktop/src/preload.ts | 10 + .../src/preview/BrowserSession.test.ts | 96 ++++++++- apps/desktop/src/preview/BrowserSession.ts | 120 ++++++++++- .../src/preview/DesktopBrowserHost.test.ts | 29 +++ .../desktop/src/preview/DesktopBrowserHost.ts | 20 ++ apps/desktop/src/preview/Manager.test.ts | 185 ++++++++++++++-- apps/desktop/src/preview/Manager.ts | 203 ++++++++++++------ apps/desktop/src/preview/PickPreload.ts | 2 + .../src/window/DesktopApplicationMenu.test.ts | 30 ++- .../src/window/DesktopApplicationMenu.ts | 34 ++- apps/desktop/src/window/DesktopWindow.test.ts | 41 +++- apps/desktop/src/window/DesktopWindow.ts | 21 ++ apps/web/src/browser/BrowserSurfaceSlot.tsx | 6 + apps/web/src/browser/ElectronBrowserHost.tsx | 31 ++- .../src/browser/browserTargetResolver.test.ts | 29 +++ apps/web/src/browser/browserTargetResolver.ts | 17 ++ .../src/browser/browserViewportLayout.test.ts | 10 +- apps/web/src/browser/browserViewportLayout.ts | 5 +- apps/web/src/browser/openFileInPreview.ts | 38 +++- apps/web/src/components/AppSidebarLayout.tsx | 9 +- apps/web/src/components/ChatView.tsx | 16 ++ .../ReopenClosedViewShortcut.test.tsx | 18 +- .../components/ReopenClosedViewShortcut.tsx | 46 ++-- .../composerContextPresentation.test.ts | 38 ++++ .../composerContextPresentation.tsx | 29 ++- .../preview/openPreviewSession.test.ts | 88 +++++++- packages/contracts/src/ipc.ts | 9 + pnpm-lock.yaml | 22 +- pnpm-workspace.yaml | 2 +- 35 files changed, 1069 insertions(+), 152 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ddef3ca9eb14..15bbbda6c6df 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,7 +24,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.4.2", + "electron": "44.4.5", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "ffi-rs": "1.3.2", diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 98e9cf364690..5caa18a53aab 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -96,6 +96,7 @@ function layerDesktopWindow( dispatchMenuAction: () => Effect.void, dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.void, + runMainContentsCommand: () => Effect.void, syncAppearance: Effect.void, }); } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 44ede122474d..d8d7813e7972 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer Effect.die("unexpected menu action"), dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.die("unexpected zoom"), + runMainContentsCommand: () => Effect.die("unexpected main contents command"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 18280333754c..b48953f117d3 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service< readonly pickFiles: ( input: ElectronDialogPickFilesInput, ) => Effect.Effect; + /** Pass `owner` to attach the box to that window so it cannot fall behind it. */ readonly showMessageBox: ( options: Electron.MessageBoxOptions, + owner?: Electron.BrowserWindow, ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } @@ -164,9 +166,12 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options) => + showMessageBox: (options, owner) => Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), + try: () => + owner === undefined + ? Electron.dialog.showMessageBox(options) + : Electron.dialog.showMessageBox(owner, options), catch: (cause) => new ElectronDialogShowMessageBoxError({ type: options.type ?? null, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b078314ac49..e6b936c100ba 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 4afc61d6f76f..94c56464f7c4 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); + yield* manager.subscribeOpenLinks((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), + ); }); export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0dd9604f9865..12b68f186089 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, @@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); }, + onOpenLink: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => { + if (typeof linkEvent !== "object" || linkEvent === null) return; + listener(linkEvent as DesktopPreviewOpenLinkEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 805c684dfbec..9799d88fdc9c 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ >(), })); +const { fromWebContents } = vi.hoisted(() => ({ + fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null), +})); + vi.mock("electron", () => ({ + BrowserWindow: { fromWebContents }, session: { fromPartition, }, })); +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as BrowserSession from "./BrowserSession.ts"; -const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); +const { showMessageBox } = vi.hoisted(() => ({ + showMessageBox: vi.fn< + (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number + >(() => 1), +})); +const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.die("unused"), + pickFiles: () => Effect.die("unused"), + showMessageBox: (options, owner) => + Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })), + showErrorBox: () => Effect.void, +}); +const layer = BrowserSession.layer.pipe( + Layer.provide(Layer.merge(NodeServices.layer, layerDialog)), +); describe("BrowserSession", () => { beforeEach(() => { @@ -168,6 +188,7 @@ describe("BrowserSession", () => { "clipboard-sanitized-write", "notifications", "geolocation", + "fullscreen", ]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( @@ -189,6 +210,73 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("opens custom-scheme links externally only after the user confirms", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const requestHandler = + sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + + const request = (externalURL: string) => + Effect.callback((resume) => { + requestHandler( + null, + "openExternal", + (granted: boolean) => resume(Effect.succeed(granted)), + { externalURL }, + ); + }); + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + assert.isTrue(yield* request("slack://open?team=T1")); + assert.isFalse(yield* request("zoommtg://zoom.us/join")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + + for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) { + assert.isFalse(yield* request(url), `${url} must never open externally`); + } + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + + // A prompt without an owner can open behind the app window, leaving the one + // prompt slot taken and every later link silently denied. + it.effect("attaches the open-externally prompt to the window hosting the preview", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const requestHandler = + sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + + const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow; + const host = { isDestroyed: () => false }; + const guest = { isDestroyed: () => false, hostWebContents: host }; + fromWebContents.mockImplementation((webContents) => + webContents === host ? hostWindow : null, + ); + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(1); + + yield* Effect.callback((resume) => { + requestHandler( + guest, + "openExternal", + (granted: boolean) => resume(Effect.succeed(granted)), + { + externalURL: "slack://open?team=T1", + }, + ); + }); + + assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves partition scope and the platform failure chain", () => { const nativeCause = new Error("native digest failed"); const platformCause = PlatformError.systemError({ @@ -218,7 +306,11 @@ describe("BrowserSession", () => { "Failed to derive a desktop preview browser partition for scope environment-a.", ); assert.notInclude(error.message, nativeCause.message); - }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto)))); + }).pipe( + Effect.provide( + BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))), + ), + ); }); it.effect("preserves session scope, partition, and the Electron failure", () => diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 9681be83fdbb..10e2a2575f38 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -1,5 +1,5 @@ -import type { Session } from "electron"; -import { session } from "electron"; +import type { Session, WebContents } from "electron"; +import { BrowserWindow, session } from "electron"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; + const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; /** * Incognito partitions deliberately omit the `persist:` prefix, which is what @@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing + // for the app window and every preview guest, so a page that goes fullscreen + // fills its own webview instead of taking over the whole T3 window. + "fullscreen", // Deliberately NOT local-fonts: preview sessions run untrusted web content, // and silently granting it would hand every page the user's installed-font // fingerprint (and font file bytes via FontData.blob()). The app's own font // picker runs in the main window session, which is unaffected by this list. ]); +/** + * Schemes a preview page may never hand to the OS, even with the user's + * consent: they read local files, run script, or are web pages that belong in + * the preview itself. + */ +const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ + "about:", + "blob:", + "chrome:", + "data:", + "devtools:", + "file:", + "filesystem:", + "http:", + "https:", + "javascript:", + "view-source:", +]); + +/** + * The URL to offer the user when a preview page navigates to a custom scheme + * such as `slack://` or `zoom://`, or `null` when it must stay denied. + */ +const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href; + } catch { + return null; + } +}; + +const MAX_PROMPT_URL_LENGTH = 300; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); +/** + * The window showing a preview guest. A `` guest has no window of its + * own, so look it up through the page that embeds it. + */ +const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { + if (guest === null || guest.isDestroyed()) return undefined; + const host = guest.hostWebContents ?? guest; + if (host.isDestroyed()) return undefined; + return BrowserWindow.fromWebContents(host) ?? undefined; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const runFork = Effect.runForkWith(yield* Effect.context()); + // One prompt at a time, so a page cannot stack dialogs by looping a deep link. + let externalPromptOpen = false; + /** + * Electron grants an external-protocol navigation through the `openExternal` + * permission and then launches the OS handler itself. Ask first: previews run + * untrusted pages, and a silent grant would let any of them launch apps. + */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null || externalPromptOpen) { + callback(false); + return; + } + externalPromptOpen = true; + const settle = (granted: boolean) => + Effect.sync(() => { + externalPromptOpen = false; + callback(granted); + }); + const shownUrl = + url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; + runFork( + electronDialog + .showMessageBox( + { + type: "question", + buttons: ["Open", "Cancel"], + defaultId: 0, + cancelId: 1, + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + }, + previewHostWindow(requester), + ) + .pipe( + Effect.map(({ response }) => response === 0), + Effect.orElseSucceed(() => false), + Effect.flatMap(settle), + Effect.onInterrupt(() => settle(false)), + ), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { // the challenge every few seconds, so logins behind it never complete // (#5002). Re-setting the unchanged native string is harmless, so it // is the rewritten string itself that trips the check. - browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); - }); + browserSession.setPermissionRequestHandler( + (webContents, permission, callback, details) => { + if (permission === "openExternal") { + confirmOpenExternal( + webContents, + "externalURL" in details ? details.externalURL : undefined, + callback, + ); + return; + } + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); + }, + ); browserSession.setPermissionCheckHandler((_webContents, permission) => ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index eff9b98b105e..81ea040701f0 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -119,4 +119,33 @@ describe("DesktopBrowserHost", () => { expect(paths).toEqual(["/srv/downloads/guid-1"]); }), ); + + it.effect("tells a download the person clicked from one the agent's input started", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + // Reading the page is not acting on it. + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ id: 1, method: "DOM.getDocument", params: {} }), + }), + ); + expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(true); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Input.dispatchMouseEvent", + params: { type: "mousePressed", x: 1, y: 1 }, + }), + }), + ); + expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); + }), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index e6349d06b916..cfb1ff753c47 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -28,6 +28,11 @@ import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); const lineEncoder = new TextEncoder(); +/** CDP commands through which an agent clicks, types, or navigates the page. */ +const AGENT_INPUT_COMMAND = + /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** How long after an agent's input a download still counts as the agent's. */ +const AGENT_DOWNLOAD_WINDOW_MS = 5_000; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -50,6 +55,8 @@ interface AttachedTab { downloadDirectory: string | null; /** The guid CDP gave the download that is about to start. */ pendingDownloadGuid: string | null; + /** When the server last sent this page input, which only an agent does. */ + agentInputAt: number; readonly onMessage: ( event: Electron.Event, method: string, @@ -72,6 +79,11 @@ export class DesktopBrowserHost extends Context.Service< readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */ readonly detach: (key: DesktopBrowserTabKey) => void; + /** + * Whether a server tab's download came from the person at this desktop: + * the server sent the page no input just before it started. + */ + readonly humanStartedDownload: (source: Electron.WebContents) => boolean; /** Points a server tab's download at the server; false for any other download. */ readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; /** The agent's cursor positions for attached tabs, keyed by their server tab. */ @@ -161,6 +173,7 @@ export const make = Effect.gen(function* () { relay: null, downloadDirectory: null, pendingDownloadGuid: null, + agentInputAt: Number.NEGATIVE_INFINITY, onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -190,9 +203,15 @@ export const make = Effect.gen(function* () { tab.relay = null; return; } + if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); relayFor(tab).receive(command.value.message); }); + const humanStartedDownload = (source: Electron.WebContents) => { + const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); + return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; + }; + // Read when a backend starts, not when the host is built. const announceAll = Effect.suspend(() => Effect.forEach( @@ -219,6 +238,7 @@ export const make = Effect.gen(function* () { attach, detach, placeDownload, + humanStartedDownload, }); }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 11ee2d83e481..84c6273aad62 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -158,21 +158,31 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); - it("keeps target=_blank links in the preview tab", () => { + it("opens target=_blank links as a new tab", () => { expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( - "navigate", + "new-tab", ); expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( - "navigate", + "new-tab", ); }); + it("keeps a form POST with a body on the in-place path", () => { + // A new tab can only reopen the URL as a GET, which would drop the body. + const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; + expect( + PreviewManager.previewWindowOpenAction({ + ...details({ disposition: "foreground-tab" }), + postBody, + }), + ).toBe("navigate"); + }); + it("does not hand a window to schemes that cannot be hardened", () => { // A popup skips the `will-attach-webview` hardening, so it only gets a window // when its preferences can be overridden. Chromium copies the guest's // preferences for `about:blank` and forbids overriding them. for (const url of [ - "about:blank", "javascript:alert(1)", "file:///etc/passwd", "vscode://vscode-remote/ssh-remote+box/tmp", @@ -181,6 +191,16 @@ describe("previewWindowOpenAction", () => { expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); } }); + + it("denies a blank window instead of blanking the opener", () => { + // MSAL opens `about:blank` first and falls back to a redirect on `null`; + // loading the blank page into the tab would destroy that fallback. + for (const url of ["about:blank", ""]) { + for (const disposition of ["new-window", "foreground-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); + } + } + }); }); const { @@ -355,7 +375,7 @@ const makeTestPreviewWebContents = ( getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -460,7 +480,7 @@ const makeFaviconWebContents = (options?: { getType: () => "webview", getURL: () => currentUrl, getTitle: () => "Preview", - isLoading: () => loading, + isLoadingMainFrame: () => loading, isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -572,6 +592,40 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const opened: Array<{ tabId: string; url: string; background: boolean }> = []; + yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); + yield* manager.createTab("tab_links"); + yield* manager.registerWebview("tab_links", 42); + const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as + | ReturnType + | undefined; + const handler = openHandler?.mock.lastCall?.[0] as ( + details: Partial, + ) => { action: string }; + + expect( + handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }), + ).toEqual({ action: "deny" }); + expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({ + action: "deny", + }); + handler({ url: "https://github.com/issues/2", disposition: "background-tab" }); + yield* settle(() => opened.length === 2); + + expect(preview.loadURL).not.toHaveBeenCalled(); + expect(opened).toEqual([ + { tabId: "tab_links", url: "https://github.com/issues/1", background: false }, + { tabId: "tab_links", url: "https://github.com/issues/2", background: true }, + ]); + }), + ), + ); + effectIt.effect("keeps preview shortcuts out of the host window", () => withManager((manager) => Effect.gen(function* () { @@ -885,7 +939,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "about:blank", getTitle: () => "", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -978,7 +1032,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "http://localhost:3200/", getTitle: () => "Preview", - isLoading: () => false, + isLoadingMainFrame: () => false, isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -1481,7 +1535,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => { if (!zoomReadable) throw new Error("zoom unavailable"); return effectiveZoom; @@ -1551,7 +1605,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: replacementSetZoomFactor, setAudioMuted: vi.fn(), @@ -1592,7 +1646,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor, setAudioMuted: vi.fn(), @@ -1639,7 +1693,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor, setAudioMuted: vi.fn(), @@ -1695,7 +1749,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -1790,7 +1844,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "http://localhost:5173/README.md", getTitle: () => "README.md", - isLoading: () => true, + isLoadingMainFrame: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -1915,7 +1969,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted, @@ -2258,7 +2312,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "localhost:5733", - isLoading: () => loading, + isLoadingMainFrame: () => loading, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -2339,6 +2393,28 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + // The frame tree is still loading the iframe; the main frame is done. + Object.assign(preview.webContents, { isLoading: () => true }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => void states.push(state)), + ); + yield* manager.createTab("tab_iframe"); + yield* manager.registerWebview("tab_iframe", 42); + + preview.listeners.get("did-start-loading")?.(); + preview.listeners.get("did-stop-loading")?.(); + yield* settle(() => states.length > 2); + expect(states.at(-1)?.navStatus.kind).toBe("Success"); + }), + ), + ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => withManager((manager) => Effect.gen(function* () { @@ -2351,7 +2427,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com:8443/path?query=value", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -2919,7 +2995,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => `https://example.com/${id}`, getTitle: () => `Example ${id}`, - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -3381,7 +3457,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -3880,7 +3956,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4026,7 +4102,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4095,6 +4171,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("crops the picked area out of a full-page capture at device scale", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + const crop = vi.fn((rect: Electron.Rectangle) => ({ + getSize: () => ({ width: rect.width, height: rect.height }), + toDataURL: () => "data:image/png;base64,cropped", + })); + const capturePage = vi.fn(async () => ({ + getSize: () => ({ width: 2000, height: 1200 }), + crop, + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage as never), { + isFocused: () => true, + once: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + }), + ); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + { x: 5, y: 6, width: 20, height: 30 }, + "attach", + 2, + ); + + const result = yield* Fiber.join(pick); + expect(capturePage).toHaveBeenCalledWith(); + expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 }); + expect(result?.screenshotFailed).toBeUndefined(); + expect(result?.annotation.screenshot).toEqual({ + dataUrl: "data:image/png;base64,cropped", + width: 40, + height: 60, + cropRect: { x: 5, y: 6, width: 20, height: 30 }, + }); + }), + ), + ); + effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => withManager((manager) => Effect.gen(function* () { @@ -4105,7 +4244,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4190,7 +4329,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index f98694145b67..123943f70f9a 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -13,6 +13,7 @@ import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -30,6 +31,7 @@ import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, ClipboardItem, + app, type Session, clipboard, nativeImage, @@ -284,61 +286,36 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; -/** `capturePage` never settles when the guest's compositor is wedged. */ -const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; - /** - * Crops the guest for a picked annotation. A stalled `capturePage` resolves to - * `null` after the timeout: the annotation is still sendable without its - * screenshot, and the pick session must settle either way. + * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px; + * the capture's pixels are CSS px × the guest's `devicePixelRatio`. */ -const captureAnnotationScreenshot = ( - tabId: string, - wc: Electron.WebContents, +const cropAnnotationScreenshot = ( + image: Electron.NativeImage, cropRect: PreviewAnnotationRect | null, -): Effect.Effect => - Effect.tryPromise({ - // The unused abort signal is what makes this interruptible, and therefore - // what lets the timeout below fire. Drop the parameter and a stalled - // capture strands the pick session again. - try: (_signal) => - wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, - ), - catch: (cause) => - new PreviewOperationError({ - operation: "captureAnnotationScreenshot", - tabId, - webContentsId: wc.id, - cause, - }), - }).pipe( - Effect.map((image): PreviewAnnotationPayload["screenshot"] => { - const size = image.getSize(); - return { - dataUrl: image.toDataURL(), - width: size.width, - height: size.height, - cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, - }; - }), - Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), - Effect.flatMap((screenshot) => - Option.isSome(screenshot) - ? Effect.succeed(screenshot.value) - : Effect.logWarning("preview annotation screenshot timed out").pipe( - Effect.annotateLogs({ tabId, webContentsId: wc.id }), - Effect.as(null), - ), - ), - ); + devicePixelRatio: number, +): PreviewAnnotationPayload["screenshot"] => { + const full = image.getSize(); + if (!cropRect) { + return { + dataUrl: image.toDataURL(), + width: full.width, + height: full.height, + cropRect: { x: 0, y: 0, width: full.width, height: full.height }, + }; + } + const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale))); + const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale))); + const cropped = image.crop({ + x, + y, + width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))), + height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))), + }); + const size = cropped.getSize(); + return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; +}; const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( @@ -437,6 +414,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; +type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; + /** * Protocols a preview page may open in a real popup window. * @@ -483,14 +462,29 @@ const POPUP_WINDOW_OPTIONS = { * navigating the preview tab instead destroys the opener the popup has to * `postMessage` its result back to. * - * `target="_blank"` links arrive as a tab disposition and keep loading in the - * preview tab, which is what people expect from a link inside a preview. + * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web + * app, which opens it as another preview tab so the page that held the link + * stays put. Schemes a popup cannot be hardened for keep loading in place, and + * so does a form POST with a body, which a new tab could only reopen as a GET. + * + * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real + * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would + * replace the opener with an empty page. Denying makes `window.open()` return + * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; -}): "popup" | "navigate" => - details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; + readonly postBody?: Electron.PostBody; +}): "popup" | "new-tab" | "navigate" | "deny" => { + if (details.url === "" || details.url === "about:blank") return "deny"; + if (!isPopupUrl(details.url)) return "navigate"; + if (details.disposition === "new-window") return "popup"; + return !details.postBody && + (details.disposition === "foreground-tab" || details.disposition === "background-tab") + ? "new-tab" + : "navigate"; +}; export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && @@ -566,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const openLinkListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -836,7 +831,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", + eventKind: + | "state-change" + | "recording-frame" + | "recording-input" + | "pointer-event" + | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1241,7 +1241,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - if (wc.isLoading()) return { kind: "Loading", url, title }; + // Main frame only. `isLoading()` covers the whole frame tree, so a + // cross-origin iframe that loads after the page can leave it true with no + // later event to clear it, and the tab's loading bar never finishes. + if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1607,9 +1610,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { - if (previewWindowOpenAction(details) === "popup") { + const action = previewWindowOpenAction(details); + if (action === "popup") { return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; } + if (action === "deny") return { action: "deny" }; + if (action === "new-tab") { + runFork( + emitOpenLink({ + tabId, + url: details.url, + background: details.disposition === "background-tab", + }), + ); + return { action: "deny" }; + } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => wc.loadURL(details.url), @@ -2234,8 +2249,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const cropRect = normalizeCaptureRect(args[1]); const submission = args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; + const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; runFork( - captureAnnotationScreenshot(tabId, wc, cropRect).pipe( + // The full-page capture with retries is what the screenshot button + // uses. A single cropped `capturePage(rect)` with a fixed timeout + // dropped the crop on pages where that capture was merely slow. + capturePageWithRetry( + { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, + tabId, + wc, + ).pipe( + Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)), + Effect.tapError((error) => + Effect.logWarning("preview annotation screenshot failed").pipe( + Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }), + ), + ), + Effect.withSpan("PreviewManager.captureAnnotationScreenshot"), // The renderer cannot tell a dropped crop from a comment-only // pick by the null alone, so a failed or timed-out capture is // flagged on the result. @@ -3320,6 +3350,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* ( + event: DesktopPreviewOpenLinkEvent, + ) { + const listeners = yield* Ref.get(openLinkListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("open-link", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3341,6 +3381,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [ Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), + Ref.set(openLinkListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], @@ -3386,6 +3427,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => @@ -3627,6 +3669,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeOpenLinks: ( + listener: OpenLinkListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3642,13 +3687,48 @@ export const make = Effect.gen(function* PreviewManagerMake() { const browserSession = yield* BrowserSession.BrowserSession; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const downloadSessions = new WeakSet(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runForkDownload = Effect.runForkWith(yield* Effect.context()); + /** Puts a copy in Downloads under a name that never replaces a file already there. */ + const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( + source: string, + fileName: string, + ) { + const directory = app.getPath("downloads"); + const extension = path.extname(fileName); + const stem = path.basename(fileName, extension) || "download"; + for (let attempt = 0; attempt < 100; attempt += 1) { + const target = path.join( + directory, + attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`, + ); + if (yield* fileSystem.exists(target)) continue; + yield* fileSystem.copyFile(source, target); + shell.showItemInFolder(target); + return; + } + }); // Server tabs save downloads where the server's engine reads them. Downloads // the person starts in a tab the server is not driving keep Electron's dialog. const placeServerDownloads = (session: Electron.Session) => { if (downloadSessions.has(session)) return; downloadSessions.add(session); session.on("will-download", (_event, item, source) => { - browserHost.placeDownload(source, item); + // Decided before the server's path is set: placing it consumes the guid. + const human = browserHost.humanStartedDownload(source); + if (!browserHost.placeDownload(source, item) || !human) return; + // The server keeps its copy for agents; the person who clicked gets theirs. + item.once("done", (_doneEvent, state) => { + if (state !== "completed") return; + runForkDownload( + copyToDownloads(item.getSavePath(), item.getFilename()).pipe( + Effect.catch((error) => + Effect.logWarning("preview download copy failed", { cause: error }), + ), + ), + ); + }); }); }; @@ -3732,6 +3812,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { saveRecording: operations.saveRecording, subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, + subscribeOpenLinks: operations.subscribeOpenLinks, subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a9155677ad38..a656070f87b4 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1410,6 +1410,8 @@ function startAnnotation(sendEnabled: boolean): void { annotation, screenshotRect, submission === "send" && !sendEnabled ? "attach" : submission, + // Main crops a full-page capture, whose pixels are CSS px × this. + window.devicePixelRatio, ); }) .catch(() => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 8de28324a58d..f111dac25db8 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -87,6 +87,8 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred) => dispatchSnapShotEvent: () => Effect.void, zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), + runMainContentsCommand: (command) => + Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); @@ -265,7 +267,9 @@ describe("DesktopApplicationMenu", () => { } assert.isUndefined( - viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + viewMenu.submenu.find((item) => + ["zoom", "reload", "devtools"].some((role) => item.role?.toLowerCase().includes(role)), + ), ); const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); @@ -279,4 +283,28 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); }), ); + + it.effect("reloads the main window even while a browser page has focus", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + if (!Array.isArray(viewMenu?.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + const reload = viewMenu.submenu.find((item) => item.label === "Reload"); + assert.equal(reload?.accelerator, "CmdOrCtrl+R"); + if (typeof reload?.click !== "function") { + throw new Error("Expected Reload menu item to have a click handler."); + } + + reload.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "main-reload"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 5aa59a2ae1b2..d0363fb01a27 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -57,6 +57,13 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( yield* desktopWindow.zoomMain(direction); }); +const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")(function* ( + command: DesktopWindow.MainWindowContentsCommand, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.runMainContentsCommand(command); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -150,6 +157,9 @@ export const make = Effect.gen(function* () { const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; + const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => { + runMenuEffect(command, runMainContentsCommand(command)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { @@ -227,16 +237,24 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, /* - Not the zoom roles: those act on the focused webContents, so with - an embedded preview WebContentsView focused they zoom the guest - page and the app UI appears stuck. These always zoom the main - window (see DesktopWindow.zoomMain). + Not the reload, DevTools or zoom roles: those act on the focused + webContents, so with a browser page focused they reload or zoom + the guest page and the app UI appears stuck. These always target + the main window (see DesktopWindow.zoomMain). */ + { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, + { + label: "Force Reload", + accelerator: "Shift+CmdOrCtrl+R", + click: mainContentsClick("forceReload"), + }, + { + label: "Toggle Developer Tools", + accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", + click: mainContentsClick("toggleDevTools"), + }, + { type: "separator" }, { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cb374b8103de..10c3cb8adee9 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -90,10 +90,12 @@ function makeFakeBrowserWindow() { once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(), openDevTools: vi.fn(), reload: vi.fn(), + reloadIgnoringCache: vi.fn(), replaceMisspelling: vi.fn(), send: vi.fn(), setBackgroundThrottling: vi.fn(), setWindowOpenHandler: vi.fn(), + toggleDevTools: vi.fn(), }; const window = { @@ -138,6 +140,8 @@ function makeFakeBrowserWindow() { maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, + reloadIgnoringCache: webContents.reloadIgnoringCache, + toggleDevTools: webContents.toggleDevTools, send: webContents.send, setZoomLevel: webContents.setZoomLevel, setWindowButtonPosition: window.setWindowButtonPosition, @@ -227,6 +231,7 @@ function layerTest(input: { readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; + readonly focusedWindow?: Electron.BrowserWindow; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -273,7 +278,10 @@ function layerTest(input: { ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), - focusedMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: + input.focusedWindow === undefined + ? Ref.get(input.mainWindow) + : Effect.succeedSome(input.focusedWindow), setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), clearMain: () => Ref.set(input.mainWindow, Option.none()), prepareReveal: () => Effect.succeed(false), @@ -782,6 +790,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("reloads and inspects the main window even when a popup has focus", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const popup = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + focusedWindow: popup.window, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.runMainContentsCommand("reload"); + yield* desktopWindow.runMainContentsCommand("forceReload"); + yield* desktopWindow.runMainContentsCommand("toggleDevTools"); + + assert.equal(fakeWindow.reload.mock.calls.length, 1); + assert.equal(fakeWindow.reloadIgnoringCache.mock.calls.length, 1); + assert.equal(fakeWindow.toggleDevTools.mock.calls.length, 1); + assert.equal(popup.reload.mock.calls.length, 0); + assert.equal(popup.reloadIgnoringCache.mock.calls.length, 0); + assert.equal(popup.toggleDevTools.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 4e8fb0969ad3..6dede819356a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -93,6 +93,7 @@ export type DesktopWindowError = | PreviewManager.PreviewManagerError; export type MainWindowZoomDirection = "in" | "out" | "reset"; +export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools"; export class DesktopWindow extends Context.Service< DesktopWindow, @@ -137,6 +138,10 @@ export class DesktopWindow extends Context.Service< // guest page instead of the app UI. The menu routes here to always target // the main window. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; + // Reload and DevTools for the main window's own webContents, for the same + // reason as zoomMain: the Electron roles act on the focused webContents, + // which is a preview guest whenever a browser page has focus. + readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -420,6 +425,10 @@ export const make = Effect.gen(function* () { nodeIntegration: false, sandbox: true, webviewTag: true, + // A preview guest's fullscreen request is mirrored onto this embedder, + // which would otherwise put the whole window into OS fullscreen. With + // both sides opted out the page fills its webview and the window stays. + disableHtmlFullscreenWindowResize: true, }, }); @@ -527,6 +536,7 @@ export const make = Effect.gen(function* () { webPreferences.nodeIntegration = false; webPreferences.nodeIntegrationInSubFrames = false; webPreferences.contextIsolation = false; + webPreferences.disableHtmlFullscreenWindowResize = true; }); const contextMenuContents = new WeakSet(); @@ -1027,6 +1037,17 @@ export const make = Effect.gen(function* () { // own zoom, so put each guest back where the preview left it. yield* previewManager.reapplyZoom(); }), + runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { + yield* Effect.annotateCurrentSpan({ command }); + // The registered main window, never the focused one: with an OAuth popup + // focused, Reload would otherwise reload the popup mid sign-in. + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) return; + const webContents = window.value.webContents; + if (command === "reload") webContents.reload(); + else if (command === "forceReload") webContents.reloadIgnoringCache(); + else webContents.toggleDevTools(); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index b875e4c221ef..c30a62274614 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -68,6 +68,12 @@ export function BrowserSurfaceSlot(props: { // full width. The slot moves without resizing, so measure on shell resizes too. const panel = element.closest('[data-preview-panel-mode="inline"]'); if (panel) observer.observe(panel); + // A sidebar opening or closing shifts the whole column, so a slot that keeps + // its size and its offset inside the column (the mini player on the left) + // still moves on screen. The gap element carries the sidebar's width. + for (const gap of document.querySelectorAll('[data-slot="sidebar-gap"]')) { + observer.observe(gap); + } window.addEventListener("resize", update); window.addEventListener("scroll", update, true); return () => { diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 5cbefd9dded6..e32dcb9ee4df 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -3,18 +3,21 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useMemo } from "react"; +import { type ComponentProps, useEffect, useMemo, useRef } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; import { useTheme } from "~/hooks/useTheme"; import { useActivePreviewSessions } from "~/previewStateStore"; +import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; +import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; +import { openUrlInPreview } from "./openFileInPreview"; import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; @@ -92,6 +95,32 @@ export function ElectronBrowserHost() { }); }, []); + // A `target="_blank"` link inside a hosted page opens as another tab of the + // same thread, so the page that held the link stays where it is. + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true }); + const sessionByRuntimeTabId = useRef(new Map()); + useEffect(() => { + sessionByRuntimeTabId.current = new Map( + sessions.map((session) => [session.runtimeTabId, session]), + ); + }, [sessions]); + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onOpenLink(({ tabId, url, background }) => { + const source = sessionByRuntimeTabId.current.get(tabId); + if (!source) return; + // The new tab keeps the source tab's profile so its cookies carry over. + void openUrlInPreview({ + threadRef: source.threadRef, + url, + openPreview, + profileId: source.snapshot.profileId, + background, + }); + }); + }, [openPreview]); + if (!isElectron) return null; return (
diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b3432402ed..c2b6c9ea64b4 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -2,8 +2,10 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const readPreparedConnection = vi.fn(); +const env = vi.hoisted(() => ({ isElectron: false })); vi.mock("~/state/session", () => ({ readPreparedConnection })); +vi.mock("~/env", () => env); describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); @@ -160,6 +162,33 @@ describe("browser target resolver", () => { ).toBe("http://192.168.1.25:3000/app"); }); + it("keeps localhost for servers on the desktop's own WSL or primary backend", async () => { + // WSL NAT mode serves the backend on the distro IP, but a loopback-only dev + // server there answers only on localhost from the Windows desktop. + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + env.isElectron = true; + try { + for (const target of [ + { _tag: "BearerConnectionTarget", connectionId: "local:wsl:Ubuntu" }, + { _tag: "PrimaryConnectionTarget" }, + ]) { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://172.24.66.27:3773", target }); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3001/"), + ).toBe("http://localhost:3001/"); + } + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://192.168.1.25:3773", + target: { _tag: "BearerConnectionTarget", connectionId: "saved:remote" }, + }); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3001/"), + ).toBe("http://192.168.1.25:3001/"); + } finally { + env.isElectron = false; + } + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 4acb1e2b487f..ec1b150819a0 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -6,6 +6,10 @@ import type { import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; + +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isElectron } from "~/env"; import { readPreparedConnection } from "~/state/session"; export { @@ -75,11 +79,24 @@ export function resolveBrowserNavigationTarget( return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } +/** + * Whether a discovered loopback server is reachable as-is from this client's + * own browser tabs. The desktop app draws tabs on the machine that runs its + * primary backend and any desktop-local backend (WSL). Its connection URL can + * still be a LAN address (WSL NAT mode binds the distro IP), but a dev server + * bound to loopback only answers on `localhost` there. + */ +const reachesEnvironmentLoopback = (target: ConnectionTarget): boolean => + isElectron && + (target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target)); + export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const target = readPreparedConnection(environmentId)?.target; + if (target && reachesEnvironmentLoopback(target)) return normalizedUrl; return resolveEnvironmentPortTarget( environmentId, { diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index 23c8a2996cc0..ca2832bea6ce 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => { expect( resolveBrowserDeviceViewportLayout( { width: 1200, height: 900 }, - { _tag: "freeform", width: 1180, height: 858 }, + { _tag: "freeform", width: 1180, height: 852 }, ), ).toEqual({ canvasWidth: 1200, canvasHeight: 900, viewportX: 10, - viewportY: 32, + viewportY: 38, viewportWidth: 1180, - viewportHeight: 858, + viewportHeight: 852, viewportScale: 1, fillsPanel: false, }); @@ -140,11 +140,11 @@ describe("resolveBrowserViewportLayout", () => { it("captures the available framed area when responsive mode is enabled", () => { expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ width: 1180, - height: 858, + height: 852, }); expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ width: 590, - height: 429, + height: 426, }); }); diff --git a/apps/web/src/browser/browserViewportLayout.ts b/apps/web/src/browser/browserViewportLayout.ts index 414701a816c5..489a8f16f391 100644 --- a/apps/web/src/browser/browserViewportLayout.ts +++ b/apps/web/src/browser/browserViewportLayout.ts @@ -19,7 +19,10 @@ export interface BrowserViewportLayout { readonly fillsPanel: boolean; } -export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; +// Room for a compact size input (30px with its border) plus its 3px focus ring +// on both sides, inside the toolbar's 1px bottom border. The bar scrolls +// horizontally, which also clips anything that overflows vertically. +export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38; export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; export type BrowserViewportResizeDirection = diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index 615b85454905..c582e5a5b6c8 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -17,8 +17,14 @@ import { AsyncResult } from "effect/reactivity"; import { resolveAssetUrl } from "~/assets/assetUrls"; import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime"; -import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; -import { useRightPanelStore } from "~/rightPanelStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + rememberPreviewUrl, + setActivePreviewTab, + updatePreviewServerSnapshot, +} from "~/previewStateStore"; +import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; import { browserDefaultOpenProfileId, @@ -52,6 +58,10 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; + /** Profile to open under; omit for the configured default. */ + readonly profileId?: PreviewOpenInput["profileId"]; + /** Open the tab without switching the thread to it. */ + readonly background?: boolean; }): Promise> { const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), @@ -60,6 +70,13 @@ export async function openUrlInPreview(input: { return AsyncResult.failure(Cause.fail(defaults)); } const runtime = previewRuntimeFor(input.threadRef.environmentId); + const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; + // The server's "opened" event switches the preview tab but not the panel's + // selection, so a changed selection means the user picked a tab themselves. + const selectedSurface = () => + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef) + ?.id ?? null; + const surfaceBeforeOpen = selectedSurface(); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -69,13 +86,26 @@ export async function openUrlInPreview(input: { // maps the result differently, so the configured defaults have to be // applied explicitly or file/link opens would ignore them. viewport: browserDefaultOpenViewport(defaults), - profileId: browserDefaultOpenProfileId(defaults), + profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), ...(runtime === undefined ? {} : { runtime }), }, }); return mapAtomCommandResult(result, (snapshot) => { - applyPreviewServerSnapshot(input.threadRef, snapshot); rememberPreviewUrl(input.threadRef, input.url); + if (input.background) { + updatePreviewServerSnapshot(input.threadRef, snapshot); + // The server's "opened" event activates the new tab; hand focus back, + // unless the user picked a tab, this one included, while the open was in flight. + if ( + previousActiveTabId && + readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId && + selectedSurface() === surfaceBeforeOpen + ) { + setActivePreviewTab(input.threadRef, previousActiveTabId); + } + return; + } + applyPreviewServerSnapshot(input.threadRef, snapshot); useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); }); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 918d28845068..00d5c05ef089 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -124,7 +124,14 @@ function SidebarControl() { // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); + // A focused desktop browser page forwards the chord as a menu action. + const unsubscribe = window.desktopBridge?.onMenuAction((action) => { + if (action === "sidebar.toggle") toggleSidebar(); + }); + return () => { + window.removeEventListener("keydown", onKeyDown, true); + unsubscribe?.(); + }; }, [keybindings, toggleSidebar, usagePageOpen]); return ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6baf94b5ff4..69a204ccd8a3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8076,6 +8076,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "rightPanel.toggleMaximized") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) toggleRightPanelMaximized(); + return; + } + if (command === "threadPanel.toggle") { event.preventDefault(); event.stopPropagation(); @@ -8313,6 +8320,7 @@ export default function ChatView(props: ChatViewProps) { closeThreadFind, isThreadFindActive, toggleRightPanel, + toggleRightPanelMaximized, toggleThreadPanel, toggleTerminalVisibility, composerRef, @@ -8324,6 +8332,14 @@ export default function ChatView(props: ChatViewProps) { onEnvironmentChange, ]); + // A focused desktop browser page forwards these chords as menu actions. + useEffect(() => { + return window.desktopBridge?.onMenuAction((action) => { + if (action === "rightPanel.toggle") toggleRightPanel(); + else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized(); + }); + }, [toggleRightPanel, toggleRightPanelMaximized]); + // Paste-to-focus: the resting composer blurs on a click into the timeline, // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. diff --git a/apps/web/src/components/ReopenClosedViewShortcut.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx index e43fd4069b69..9add737a2944 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx @@ -309,17 +309,27 @@ describe("root reopen shortcut", () => { ); it("forwards the chord to a focused desktop browser only while history exists", async () => { + const forwardedCommands = () => + (state.setShortcuts.mock.lastCall as unknown as [Array<{ command: string }>])[0].map( + ({ command }) => command, + ); useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); await render(); - expect(state.setShortcuts).toHaveBeenLastCalledWith([ - expect.objectContaining({ command: "view.reopenClosed" }), - ]); + expect(forwardedCommands()).toContain("view.reopenClosed"); await act(() => { menuAction?.("view.reopenClosed"); }); expect(useClosedViewStore.getState().entries).toEqual([]); - expect(state.setShortcuts).toHaveBeenLastCalledWith([]); + expect(forwardedCommands()).not.toContain("view.reopenClosed"); + }); + + it("always forwards the layout toggles out of a focused desktop browser", async () => { + await render(); + expect(state.setShortcuts).toHaveBeenLastCalledWith([ + expect.objectContaining({ command: "sidebar.toggle" }), + expect.objectContaining({ command: "rightPanel.toggle" }), + ]); }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index d8e3b65a5c9e..41cc2144deec 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -43,6 +43,13 @@ import { } from "../threadRoutes"; import { toastManager } from "./ui/toast"; +/** Layout commands a focused desktop browser page hands back to the app. */ +const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ + "sidebar.toggle", + "rightPanel.toggle", + "rightPanel.toggleMaximized", +] as const; + const isGlobalPullRequests = (ref: ScopedThreadRef) => scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF); @@ -175,22 +182,33 @@ export function ReopenClosedViewShortcut() { useEffect(() => { const preview = window.desktopBridge?.preview; if (!preview?.setForwardedShortcuts) return; + const options = { + context: { + previewFocus: true, + previewOpen: true, + terminalFocus: false, + terminalOpen, + editableFocus: false, + modelPickerOpen: false, + isDesktop: true, + isWeb: false, + }, + }; + // A focused browser page is a separate process, so these chords never reach + // the app's keydown listeners. The desktop claims them in the guest and + // sends each command back as a menu action. + const commands = [ + ...(hasHistory ? (["view.reopenClosed"] as const) : []), + ...PREVIEW_FORWARDED_LAYOUT_COMMANDS, + ]; void preview .setForwardedShortcuts( - hasHistory - ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", { - context: { - previewFocus: true, - previewOpen: true, - terminalFocus: false, - terminalOpen, - editableFocus: false, - modelPickerOpen: false, - isDesktop: true, - isWeb: false, - }, - }).map((shortcut) => ({ command: "view.reopenClosed", shortcut })) - : [], + commands.flatMap((command) => + effectiveShortcutsForCommand(keybindings, command, options).map((shortcut) => ({ + command, + shortcut, + })), + ), ) .catch(() => undefined); return () => { diff --git a/apps/web/src/components/composerContextPresentation.test.ts b/apps/web/src/components/composerContextPresentation.test.ts index b4eb4bbe0cb3..4c82eb94ef24 100644 --- a/apps/web/src/components/composerContextPresentation.test.ts +++ b/apps/web/src/components/composerContextPresentation.test.ts @@ -8,6 +8,44 @@ import { } from "./composerContextPresentation"; describe("composerContextRecordsFromDraft", () => { + it("pairs a picked element with its crop, which the draft keeps as an image", () => { + const annotation = { + id: "pick-1", + pageUrl: "http://localhost:3000/", + pageTitle: "Example", + comment: "", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + // The draft stores annotations without their data URL. + screenshot: { + dataUrl: "", + width: 10, + height: 10, + cropRect: { x: 0, y: 0, width: 10, height: 10 }, + }, + createdAt: "2026-10-08T00:00:00.000Z", + }; + const crop = { + type: "image" as const, + id: "pick-1", + name: "preview-annotation-pick-1.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "blob:crop", + file: new File(["x"], "preview-annotation-pick-1.png"), + }; + + expect( + composerContextRecordsFromDraft({ + terminalContexts: [], + previewAnnotations: [annotation], + images: [crop], + }).get("preview-annotation_pick-1"), + ).toMatchObject({ kind: "preview-annotation", screenshot: { previewUrl: "blob:crop" } }); + }); + it("recovers the uploaded record when clipboard data points at an attachment already in the draft", () => { const file = { type: "file" as const, diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index 7e639e05793c..e8e493be73c6 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -55,7 +55,12 @@ import { export type ComposerDraftContextRecord = | { kind: "terminal"; record: TerminalContextDraft } | { kind: "review-comment"; record: ReviewCommentContext } - | { kind: "preview-annotation"; record: PreviewAnnotationPayload } + | { + kind: "preview-annotation"; + record: PreviewAnnotationPayload; + /** The crop, which the draft stores as an image sharing the annotation's id. */ + screenshot?: ComposerImageAttachment | undefined; + } | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined } | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined } | { kind: "thread"; record: ThreadContextRecord }; @@ -122,8 +127,13 @@ export function composerContextRecordsFromDraft(input: { for (const record of input.reviewComments ?? []) { records.set(reviewCommentContextId(record.id), { kind: "review-comment", record }); } + const imagesById = new Map((input.images ?? []).map((image) => [image.id, image])); for (const record of input.previewAnnotations ?? []) { - records.set(previewAnnotationContextId(record.id), { kind: "preview-annotation", record }); + records.set(previewAnnotationContextId(record.id), { + kind: "preview-annotation", + record, + screenshot: imagesById.get(record.id), + }); } for (const record of input.threadContexts ?? []) { records.set(record.contextId, { kind: "thread", record }); @@ -291,15 +301,19 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte function ComposerPreviewAnnotationDetails({ annotation, + screenshot, }: { annotation: PreviewAnnotationPayload; + screenshot: ComposerImageAttachment | undefined; }) { const summary = previewAnnotationTooltip(annotation); + // The draft drops the annotation's own data URL and keeps the crop as an image. + const screenshotUrl = annotation.screenshot?.dataUrl || screenshot?.previewUrl; return (
- {annotation.screenshot?.dataUrl ? ( + {screenshotUrl ? ( Annotated preview crop @@ -406,7 +420,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry< icon={} label={previewAnnotationContextLabel(entry.record)} kindLabel="Preview annotation" - details={} + details={ + + } detailsMode={definition.capabilities.details} kind="preview-annotation" /> diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 70751aef8342..792a20b7c4aa 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -13,7 +13,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import * as browserDefaults from "~/browser/browserDefaults"; import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; -import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, + setActivePreviewTab, +} from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -37,6 +43,7 @@ const snapshot: PreviewSessionSnapshot = { beforeEach(() => { resetPreviewStateForTests(); + useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} }); __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); }); @@ -140,3 +147,82 @@ describe("openPreviewSession", () => { }, ); }); + +describe("openUrlInPreview from a link", () => { + it("opens under the source tab's profile instead of the default", async () => { + const openPreview = vi.fn(async (_arg: { input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ); + + await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/", profileId: "work" }); + + expect(openPreview.mock.calls[0]?.[0].input.profileId).toBe("work"); + }); + + it("keeps the current tab active for a background open", async () => { + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); + + await openUrlInPreview({ + openPreview: async () => AsyncResult.success(snapshot), + threadRef, + url: "https://t3.chat/", + background: true, + }); + + const state = readThreadPreviewState(threadRef); + expect(state.activeTabId).toBe("tab-current"); + expect(Object.keys(state.sessions).toSorted()).toEqual(["tab-1", "tab-current"]); + }); + + it("keeps a tab the user picked while a background open was in flight", async () => { + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-other" }); + setActivePreviewTab(threadRef, "tab-current"); + + await openUrlInPreview({ + openPreview: async () => { + // The server activates the new tab, then the user selects another one + // before the open resolves. + applyPreviewServerSnapshot(threadRef, snapshot); + setActivePreviewTab(threadRef, "tab-other"); + return AsyncResult.success(snapshot); + }, + threadRef, + url: "https://t3.chat/", + background: true, + }); + + expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-other"); + }); + + it("keeps the new tab when the user picks it while a background open is in flight", async () => { + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); + setActivePreviewTab(threadRef, "tab-current"); + + await openUrlInPreview({ + openPreview: async () => { + // The server activates the new tab, then the user clicks that same tab. + applyPreviewServerSnapshot(threadRef, snapshot); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); + return AsyncResult.success(snapshot); + }, + threadRef, + url: "https://t3.chat/", + background: true, + }); + + expect(readThreadPreviewState(threadRef).activeTabId).toBe(snapshot.tabId); + }); + + it("activates the new tab for a foreground open", async () => { + applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); + + await openUrlInPreview({ + openPreview: async () => AsyncResult.success(snapshot), + threadRef, + url: "https://t3.chat/", + }); + + expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-1"); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 149f282194a5..5cee1f9280a7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -658,6 +658,14 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } +/** A `target="_blank"` link the previewed page asked to open beside itself. */ +export interface DesktopPreviewOpenLinkEvent { + tabId: string; + url: string; + /** True for middle-click / Cmd-click, which should not take focus. */ + background: boolean; +} + /** Recording decorations are forwarded separately from the captured page pixels. */ export const DesktopPreviewRecordingInputSchema = Schema.Union([ Schema.Struct({ @@ -1331,6 +1339,7 @@ export interface DesktopPreviewBridge { }; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; + onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void; } export type ConfirmDialogVariant = "default" | "destructive"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b948baba7c65..edc6c3e9a99a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,7 +185,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.0.44 - version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.3.0(react@19.3.0))(react@19.2.6) + version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -220,8 +220,8 @@ importers: specifier: 4.0.1 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) electron: - specifier: 44.4.2 - version: 44.4.2 + specifier: 44.4.5 + version: 44.4.5 electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -687,7 +687,7 @@ importers: version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': specifier: 0.0.44 - version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': specifier: 6.16.1 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -7727,8 +7727,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@44.4.2: - resolution: {integrity: sha512-872t/+8M8TyribWm2cYpRwHY2aDlyDHOn+CHnhN9kHQStVW8pAijIhFolYA/qQyDdri28Xp8Mi3HEj5kFvajCw==} + electron@44.4.5: + resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==} engines: {node: '>= 22.12.0'} hasBin: true @@ -12858,12 +12858,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - electron: 44.4.2 + electron: 44.4.5 react: 19.2.6 tslib: 2.8.1 optionalDependencies: @@ -12871,12 +12871,12 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)': + '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)': dependencies: '@clerk/clerk-js': 6.32.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6) '@clerk/react': 6.16.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6) '@clerk/shared': 4.33.0(react-dom@19.3.0(react@19.3.0))(react@19.2.6) - electron: 44.4.2 + electron: 44.4.5 react: 19.2.6 tslib: 2.8.1 optionalDependencies: @@ -18180,7 +18180,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.4.2: + electron@44.4.5: dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7f0a786151a..fe0ca51174b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -130,7 +130,7 @@ minimumReleaseAgeExclude: - expo-sharing@58.0.13 - expo-sqlite@58.0.8 - expo-symbols@58.0.3 - - electron@44.4.2 + - electron@44.4.5 - "@anthropic-ai/claude-agent-sdk@0.3.276" - "@alchemy.run/cloudflare-runtime@2.0.0-beta.80" - "@alchemy.run/floci@2.0.0-beta.80" From 66298aa926e6a4c95ebecbc576a68634315899c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:39:34 -0700 Subject: [PATCH 033/377] fix(web): desktop opens remote environments' browser tabs locally (#17316) Co-authored-by: Claude Opus 5.5 --- apps/web/src/browser/previewRuntime.test.ts | 60 ++++++++++++++++++ apps/web/src/browser/previewRuntime.ts | 32 +++++++++- .../components/preview/PreviewMoreMenu.tsx | 4 ++ .../components/preview/PreviewUnreachable.tsx | 12 +++- .../components/preview/PreviewView.test.tsx | 3 +- .../src/components/preview/PreviewView.tsx | 62 ++++++++++++++++++- .../components/preview/openPreviewSession.ts | 7 ++- 7 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/browser/previewRuntime.test.ts diff --git a/apps/web/src/browser/previewRuntime.test.ts b/apps/web/src/browser/previewRuntime.test.ts new file mode 100644 index 000000000000..951b52e90468 --- /dev/null +++ b/apps/web/src/browser/previewRuntime.test.ts @@ -0,0 +1,60 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + desktop: false, + primary: null as string | null, + serverBrowser: new Set(), +})); + +vi.mock("~/env", () => ({ isElectron: false })); +vi.mock("~/previewStateStore", () => ({ isPreviewSupportedInRuntime: () => state.desktop })); +vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => state.primary } })); +vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); +vi.mock("~/state/entities", () => ({ + readEnvironmentSupportsServerBrowser: (id: string) => state.serverBrowser.has(id), + useEnvironmentSupportsServerBrowser: () => false, +})); + +import { alternatePreviewRuntime, previewRuntimeFor } from "./previewRuntime"; + +const local = "local" as EnvironmentId; +const remote = "remote" as EnvironmentId; + +afterEach(() => { + state.desktop = false; + state.primary = null; + state.serverBrowser = new Set(); +}); + +describe("previewRuntimeFor", () => { + it("opens a remote environment's tabs on this computer in the desktop app", () => { + state.desktop = true; + state.primary = local; + state.serverBrowser = new Set([local, remote]); + + expect(previewRuntimeFor(remote)).toBeUndefined(); + expect(previewRuntimeFor(local)).toBe("server"); + }); + + it("uses the environment's browser where the client has none of its own", () => { + state.serverBrowser = new Set([remote]); + + expect(previewRuntimeFor(remote)).toBe("server"); + }); +}); + +describe("alternatePreviewRuntime", () => { + it("moves a remote environment's tab between this computer and the environment", () => { + state.desktop = true; + + expect(alternatePreviewRuntime(remote, local, true, {})).toBe("server"); + expect(alternatePreviewRuntime(remote, local, true, { runtime: "server" })).toBe("desktop"); + expect(alternatePreviewRuntime(local, local, true, {})).toBeNull(); + expect(alternatePreviewRuntime(remote, local, false, {})).toBeNull(); + }); + + it("offers no move outside the desktop app", () => { + expect(alternatePreviewRuntime(remote, null, true, { runtime: "server" })).toBeNull(); + }); +}); diff --git a/apps/web/src/browser/previewRuntime.ts b/apps/web/src/browser/previewRuntime.ts index d9b21e18c6e1..435eb07bbc3c 100644 --- a/apps/web/src/browser/previewRuntime.ts +++ b/apps/web/src/browser/previewRuntime.ts @@ -3,14 +3,44 @@ import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3t import { isElectron } from "~/env"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { readEnvironmentSupportsServerBrowser, useEnvironmentSupportsServerBrowser, } from "~/state/entities"; +/** + * Where a tab the user opens should run. The desktop app draws its own + * server's tabs natively, so those stay agent-drivable at no cost. A remote + * environment's tabs would stream, so the desktop opens them in its own + * browser instead: no latency, and it reaches what this computer reaches. The + * user can move a tab to the environment when only it can reach the page. + */ export function previewRuntimeFor(environmentId: EnvironmentId): PreviewRuntime | undefined { - return readEnvironmentSupportsServerBrowser(environmentId) ? "server" : undefined; + if (!readEnvironmentSupportsServerBrowser(environmentId)) return undefined; + if ( + isPreviewSupportedInRuntime() && + environmentId !== appAtomRegistry.get(primaryEnvironmentIdAtom) + ) { + return undefined; + } + return "server"; +} + +/** + * The other browser a tab can move to, or null when it has none: a desktop + * tab of a remote environment can move to that environment's browser, and back. + */ +export function alternatePreviewRuntime( + environmentId: EnvironmentId, + primaryEnvironmentId: EnvironmentId | null, + serverBrowser: boolean, + snapshot: Pick | null | undefined, +): PreviewRuntime | null { + if (!snapshot || !serverBrowser || !isPreviewSupportedInRuntime()) return null; + if (environmentId === primaryEnvironmentId) return null; + return snapshot.runtime === "server" ? "desktop" : "server"; } /** Electron hosts its own browser tabs; other clients need the environment to host them. */ diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 3f86233a63f0..5e715f9d96a6 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -64,6 +64,8 @@ interface Props { nativePictureInPicture: boolean; /** Profile display name, shown so the menu says which data is being cleared. */ profileName: string | undefined; + /** Reopens the tab in the other browser it can run in. */ + move?: { readonly label: string; readonly onMove: () => void }; } const MenuTriggerButton = () => ( @@ -91,6 +93,7 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, profileName, + move, }: Props) { const zoomLabel = `${Math.round(zoomFactor * 100)}%`; const disabled = !enabled; @@ -113,6 +116,7 @@ export function PreviewMoreMenu({ : "Open separate preview window"} ) : null} + {move ? {move.label} : null} {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index f44a33e931d5..2d6d8a0177a8 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -11,6 +11,8 @@ interface Props { /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */ description: string; onReload: () => void; + /** Reopens the page in a browser that may reach it, such as the environment's. */ + move?: { readonly label: string; readonly onMove: () => void }; } /** @@ -58,7 +60,7 @@ export function PreviewFileNotShown({ } /** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ -export function PreviewUnreachable({ url, code, description, onReload }: Props) { +export function PreviewUnreachable({ url, code, description, onReload, move }: Props) { const [showDetails, setShowDetails] = useState(false); const host = safeHost(url) ?? url; const friendly = describePreviewError(description); @@ -81,6 +83,9 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
  • Checking your connection
  • Confirming the dev server is running
  • + {move ? ( +
  • Opening it in the environment’s browser, which reaches its network
  • + ) : null}
  • Checking the proxy and the firewall
@@ -100,6 +105,11 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props) {showDetails ? "Hide details" : "Details"}
+ {move ? ( + + ) : null} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 522cda3d42c5..09eb7ee8e1d5 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -151,10 +151,11 @@ vi.mock("~/previewStateStore", () => ({ vi.mock("~/state/environments", () => ({ useEnvironment: () => ({ label: "WSL" }), useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773", + usePrimaryEnvironmentId: () => null, })); vi.mock("~/state/preview", () => ({ - previewEnvironment: { open: {}, resize: {} }, + previewEnvironment: { open: {}, close: {}, resize: {} }, })); vi.mock("~/state/use-atom-command", () => ({ diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 44a261ac6d78..14f1587d82d6 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -36,7 +36,12 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import { useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { useEnvironmentSupportsServerBrowser } from "~/state/entities"; +import { + useEnvironment, + useEnvironmentHttpBaseUrl, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { usePreviewStreamAccess } from "~/state/previewStream"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -50,6 +55,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; +import { closePreviewSession } from "./closePreviewSession"; import { openPreviewSession } from "./openPreviewSession"; import { showPreviewPopup } from "./showPreviewPopup"; import { PreviewChromeRow } from "./PreviewChromeRow"; @@ -69,7 +75,7 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; import { Badge } from "~/components/ui/badge"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; -import { useRendersServerTabNatively } from "~/browser/previewRuntime"; +import { alternatePreviewRuntime, useRendersServerTabNatively } from "~/browser/previewRuntime"; import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface"; import { cn } from "~/lib/utils"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; @@ -169,6 +175,10 @@ export function PreviewView({ ? new URL(environmentHttpBaseUrl).hostname : null; const open = useAtomCommand(previewEnvironment.open); + const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); + const environmentLabel = useEnvironment(threadRef.environmentId)?.label ?? "the environment"; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId); const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom"); @@ -438,6 +448,48 @@ export function PreviewView({ void localApi.shell.openExternal(url).catch(() => undefined); }, [url]); + // A desktop tab of a remote environment can only reach what this computer + // reaches; the environment's browser reaches its own network, and its agents. + const moveTarget = alternatePreviewRuntime( + threadRef.environmentId, + primaryEnvironmentId, + serverBrowser, + snapshot, + ); + const moveLabel = + moveTarget === "server" ? `Open in ${environmentLabel}'s browser` : "Open on this computer"; + const handleMoveTab = useCallback(async () => { + if (!moveTarget || !tabId || !snapshot) return; + const result = await openPreviewSession({ + openPreview: open, + threadRef, + // Loopback means the environment's machine, which this computer reaches by its address. + ...(url + ? { + url: + moveTarget === "desktop" + ? resolveDiscoveredServerUrl(threadRef.environmentId, url) + : url, + } + : {}), + viewport, + ...(snapshot.profileId === undefined ? {} : { profileId: snapshot.profileId }), + runtime: moveTarget, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to move the browser tab", + description: error instanceof Error ? error.message : "An error occurred.", + }); + return; + } + useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId); + await closePreviewSession({ closePreview, snapshot, tabId, threadRef }); + }, [closePreview, moveTarget, open, snapshot, tabId, threadRef, url, viewport]); + const handlePictureInPicture = useCallback(() => { if (!tabId) return; if (miniPlayerTabId === tabId) { @@ -977,6 +1029,9 @@ export function PreviewView({ (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false)) } actions={moreMenuActions} + {...(moveTarget + ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } } + : {})} profileName={activeProfileName} zoomFactor={ serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1) @@ -1088,6 +1143,9 @@ export function PreviewView({ code={navStatus.code} description={navStatus.description} onReload={handleRefresh} + {...(moveTarget === "server" + ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } } + : {})} />
) : null} diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 456d98d13543..eb407d22b00c 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, PreviewOpenInput, + PreviewRuntime, PreviewSessionSnapshot, PreviewViewportSetting, ScopedThreadRef, @@ -29,6 +30,8 @@ interface OpenPreviewSessionInput { viewport?: PreviewViewportSetting; /** Overrides the configured default profile. */ profileId?: string; + /** Overrides where the tab runs, such as when moving a tab between browsers. */ + runtime?: PreviewRuntime; } export async function openPreviewSession( @@ -42,7 +45,7 @@ export async function openPreviewSession( if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const runtime = previewRuntimeFor(input.threadRef.environmentId); + const runtime = input.runtime ?? previewRuntimeFor(input.threadRef.environmentId); const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -50,7 +53,7 @@ export async function openPreviewSession( ...(input.url === undefined ? {} : { url: input.url }), viewport: input.viewport ?? browserDefaultOpenViewport(defaults), profileId: input.profileId ?? browserDefaultOpenProfileId(defaults), - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }), }, }); if (result._tag === "Failure") { From 3bfbc37dbf37095816eda500f3f1e05cb98bb95b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:39:48 -0700 Subject: [PATCH 034/377] fix(desktop): the t3 command warns instead of installing behind another t3 (#17351) Co-authored-by: Claude Opus 5.5 --- .../desktop/src/app/DesktopCliCommand.test.ts | 32 ++++++++++++++----- apps/desktop/src/app/DesktopCliCommand.ts | 23 +++++++++++-- .../settings/CliCommandSettingsRow.tsx | 12 ++++--- packages/contracts/src/ipc.ts | 5 +++ 4 files changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index dc2e7fb962d1..b2305fe9840a 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; +// The machine's own PATH may already hold a t3; each test sets the PATH it means. +const machinePath = process.env.PATH; +beforeEach(() => { + process.env.PATH = ""; +}); +afterEach(() => { + process.env.PATH = machinePath; +}); + it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { @@ -165,7 +174,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("reports when another t3 earlier on PATH would run instead", () => + it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -173,16 +182,23 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const previous = process.env.PATH; process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); - yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); const command = yield* commandIn({ home }); + const theirs = path.join(shadow, "t3"); + expect((yield* command.state).shadowedBy).toBe(theirs); + // A link behind it would never run, so nothing is created. + const error = yield* Effect.flip(command.install); + expect(error.message).toContain(theirs); + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + + yield* fs.remove(theirs); const installed = yield* command.install; - expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); - expect(installed.onPath).toBe(false); - yield* fs.remove(path.join(shadow, "t3")); - expect((yield* command.state).onPath).toBe(true); + expect(installed).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + expect(installed.shadowedBy).toBeUndefined(); }).pipe(Effect.scoped), ); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 534af3716db7..d79cab92f7cc 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -146,6 +146,14 @@ export const make = Effect.gen(function* () { return Option.none(); }); + /** The `t3` a new shell runs when it is not this app's, on Unix. */ + const foreignFirstOnPath = Effect.gen(function* () { + if (windows) return Option.none(); + const first = yield* firstOnPath; + if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none(); + return first; + }); + /** Where this app's command is installed now, if anywhere. */ const installedAt = Effect.gen(function* () { if (windows) { @@ -167,12 +175,16 @@ export const make = Effect.gen(function* () { return { supported: false, installedPath: null, onPath: false } as const; } const installed = yield* installedAt; - if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + const shadowedBy = yield* foreignFirstOnPath; + const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; + if (Option.isNone(installed)) { + return { supported: true, installedPath: null, onPath: false, ...shadow }; + } // On Windows only terminals opened after the change see it. On Unix the // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath }; + return { supported: true, installedPath: installed.value, onPath, ...shadow }; }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ @@ -209,6 +221,13 @@ export const make = Effect.gen(function* () { .remove(existing.value) .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } + // A link behind another `t3` never runs, so installing one would only hide the problem. + const shadowedBy = yield* foreignFirstOnPath; + if (Option.isSome(shadowedBy)) { + return yield* fail( + `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, + ); + } const onPath = pathEntries(process.env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); // Prefer a folder already on PATH that the user can write to without admin rights. diff --git a/apps/web/src/components/settings/CliCommandSettingsRow.tsx b/apps/web/src/components/settings/CliCommandSettingsRow.tsx index f7c12345d7db..6940a5bf3953 100644 --- a/apps/web/src/components/settings/CliCommandSettingsRow.tsx +++ b/apps/web/src/components/settings/CliCommandSettingsRow.tsx @@ -51,11 +51,13 @@ export function CliCommandSettingsRow() { if (!bridge || !state?.supported) return null; const installed = state.installedPath !== null; - const description = !installed - ? "Run T3 Code's CLI as `t3` from any terminal." - : state.onPath - ? `Installed at ${state.installedPath}. Open a new terminal to use it.` - : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; + const description = state.shadowedBy + ? `Another t3 at ${state.shadowedBy} runs first in a new terminal. Remove it to use T3 Code's.` + : !installed + ? "Run T3 Code's CLI as `t3` from any terminal." + : state.onPath + ? `Installed at ${state.installedPath}. Open a new terminal to use it.` + : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; return ( Date: Thu, 8 Oct 2026 16:40:02 -0700 Subject: [PATCH 035/377] fix(web): images, video, HTML and PDF preview in a thread before its first message (#17352) Co-authored-by: Claude Opus 5.5 --- .../components/files/FilePreviewPanel.test.ts | 30 ++++++++ .../src/components/files/FilePreviewPanel.tsx | 77 +++++++++++++------ .../src/components/files/filePreviewMode.ts | 19 ++++- 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 84fb69e79cf7..45304cb58639 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -1,3 +1,4 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; import { ProjectReadFileError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; @@ -13,6 +14,7 @@ import { resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, + workspaceAssetResource, } from "./filePreviewMode"; const decodeReadError = Schema.decodeSync(ProjectReadFileError); @@ -189,3 +191,31 @@ describe("resolveFilePreviewPath", () => { } }); }); + +describe("workspaceAssetResource", () => { + const input = { + kind: "workspace-file" as const, + threadRef: { + environmentId: "env" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], + }, + workspaceRoot: "/repo", + absolutePath: "/repo/docs/index.html", + }; + + it("names the thread once the server knows it", () => { + expect(workspaceAssetResource({ ...input, draft: false })).toEqual({ + _tag: "workspace-file", + threadId: "thread-1", + path: "/repo/docs/index.html", + }); + }); + + it("names the workspace root for a draft, which the server cannot resolve from a thread", () => { + expect(workspaceAssetResource({ ...input, draft: true })).toEqual({ + _tag: "draft-workspace-file", + cwd: "/repo", + path: "/repo/docs/index.html", + }); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index be77a04d43e6..efd7a7fbc90f 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -106,6 +106,7 @@ import { resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, + workspaceAssetResource, } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { @@ -147,18 +148,23 @@ type FilePostRender = ( function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; + /** The thread is a draft the server does not know yet. */ + readonly draft: boolean; readonly absolutePath: string; readonly workspaceRoot: string; readonly alt: string; readonly workspaceMutationId: string | null; }) { const resource = useMemo( - () => ({ - _tag: "workspace-file" as const, - threadId: props.threadRef.threadId, - path: props.absolutePath, - }), - [props.threadRef.threadId, props.absolutePath], + () => + workspaceAssetResource({ + kind: "workspace-file", + threadRef: props.threadRef, + draft: props.draft, + workspaceRoot: props.workspaceRoot, + absolutePath: props.absolutePath, + }), + [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState(null); @@ -213,6 +219,8 @@ function WorkspaceImagePreview(props: { function WorkspaceBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; + /** The thread is a draft the server does not know yet. */ + readonly draft: boolean; readonly absolutePath: string; readonly workspaceRoot: string; readonly title: string; @@ -221,12 +229,15 @@ function WorkspaceBrowserPreview(props: { const insideWorkspace = mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined; const resource = useMemo( - () => ({ - _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const), - threadId: props.threadRef.threadId, - path: props.absolutePath, - }), - [insideWorkspace, props.threadRef.threadId, props.absolutePath], + () => + workspaceAssetResource({ + kind: insideWorkspace ? "workspace-file" : "media-file", + threadRef: props.threadRef, + draft: props.draft, + workspaceRoot: props.workspaceRoot, + absolutePath: props.absolutePath, + }), + [insideWorkspace, props.threadRef, props.draft, props.workspaceRoot, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const revisionSuffix = @@ -260,18 +271,23 @@ function WorkspaceBrowserPreview(props: { function WorkspaceVideoPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; + /** The thread is a draft the server does not know yet. */ + readonly draft: boolean; readonly absolutePath: string; readonly workspaceRoot: string; readonly name: string; readonly workspaceMutationId: string | null; }) { const resource = useMemo( - () => ({ - _tag: "media-file" as const, - threadId: props.threadRef.threadId, - path: props.absolutePath, - }), - [props.threadRef.threadId, props.absolutePath], + () => + workspaceAssetResource({ + kind: "media-file", + threadRef: props.threadRef, + draft: props.draft, + workspaceRoot: props.workspaceRoot, + absolutePath: props.absolutePath, + }), + [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); @@ -314,17 +330,23 @@ function WorkspaceVideoPreview(props: { function WorkspaceAudioPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; + /** The thread is a draft the server does not know yet. */ + readonly draft: boolean; readonly absolutePath: string; + readonly workspaceRoot: string; readonly name: string; readonly workspaceMutationId: string | null; }) { const resource = useMemo( - () => ({ - _tag: "media-file" as const, - threadId: props.threadRef.threadId, - path: props.absolutePath, - }), - [props.threadRef.threadId, props.absolutePath], + () => + workspaceAssetResource({ + kind: "media-file", + threadRef: props.threadRef, + draft: props.draft, + workspaceRoot: props.workspaceRoot, + absolutePath: props.absolutePath, + }), + [props.threadRef, props.draft, props.workspaceRoot, props.absolutePath], ); const assetUrl = useAssetUrlState(props.environmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); @@ -1022,6 +1044,8 @@ export default function FilePreviewPanel({ }: FilePreviewPanelProps) { const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; + // A draft's composer target is its draft id; a thread the server knows is a ref. + const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); @@ -1334,6 +1358,7 @@ export default function FilePreviewPanel({ key={`${environmentId}:${threadRef.threadId}:${absolutePath}`} environmentId={environmentId} threadRef={threadRef} + draft={draft} absolutePath={absolutePath} workspaceRoot={cwd} name={relativePath} @@ -1344,7 +1369,9 @@ export default function FilePreviewPanel({ key={`${environmentId}:${threadRef.threadId}:${absolutePath}`} environmentId={environmentId} threadRef={threadRef} + draft={draft} absolutePath={absolutePath} + workspaceRoot={cwd} name={relativePath} workspaceMutationId={workspaceMutationId} /> @@ -1353,6 +1380,7 @@ export default function FilePreviewPanel({ key={absolutePath} environmentId={environmentId} threadRef={threadRef} + draft={draft} absolutePath={absolutePath} workspaceRoot={cwd} alt={relativePath} @@ -1363,6 +1391,7 @@ export default function FilePreviewPanel({ key={absolutePath} environmentId={environmentId} threadRef={threadRef} + draft={draft} absolutePath={absolutePath} workspaceRoot={cwd} title={relativePath} diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 151855404ddc..3d1ad62b68c4 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,4 +1,4 @@ -import type { ProjectReadFileError } from "@t3tools/contracts"; +import type { AssetResource, ProjectReadFileError, ScopedThreadRef } from "@t3tools/contracts"; import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path"; /** Resolve workspace links before choosing between the explorer and a file preview. */ @@ -57,3 +57,20 @@ export function setMarkdownTaskChecked( return `${markdown.slice(0, markerOffset + 1)}${checked ? "x" : " "}${markdown.slice(markerOffset + 2)}`; } + +/** + * The asset for a file the panel shows. A draft has no thread on the server + * yet, so it names its workspace root instead of a thread to resolve one from. + */ +export function workspaceAssetResource(input: { + readonly kind: "workspace-file" | "media-file"; + readonly threadRef: ScopedThreadRef; + readonly draft: boolean; + readonly workspaceRoot: string; + readonly absolutePath: string; +}): AssetResource { + if (input.draft) { + return { _tag: "draft-workspace-file", cwd: input.workspaceRoot, path: input.absolutePath }; + } + return { _tag: input.kind, threadId: input.threadRef.threadId, path: input.absolutePath }; +} From 5b78b34bba8645a268900f946858ea1470621c41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 16:41:20 -0700 Subject: [PATCH 036/377] refactor(provider-muse): move Muse Code into its own provider package (#17331) Co-authored-by: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/components/ProviderIcon.tsx | 12 --- apps/mobile/src/lib/providerClients.ts | 3 +- apps/server/package.json | 1 + .../scripts/record-muse-msp-replay-fixture.ts | 7 +- .../Adapters/MuseAdapterV2.testkit.ts | 30 +++--- apps/server/src/provider/GrokProvider.test.ts | 2 +- apps/server/src/provider/builtInDrivers.ts | 2 +- .../ClaudeTextGeneration.test.ts | 2 +- .../CodexTextGeneration.test.ts | 2 +- .../textGeneration/GrokTextGeneration.test.ts | 2 +- apps/web/package.json | 1 + apps/web/src/components/Icons.tsx | 10 -- .../components/chat/ProviderInstanceIcon.tsx | 2 - .../components/settings/providerDriverMeta.ts | 9 +- package.json | 2 +- packages/contracts/src/settings.ts | 23 ----- packages/provider-muse/package.json | 41 ++++++++ packages/provider-muse/src/client.ts | 27 ++++++ packages/provider-muse/src/server.ts | 8 ++ .../provider-muse/src/server/adapter.test.ts | 19 ++-- .../provider-muse/src/server/adapter.ts | 33 +++---- .../provider-muse/src/server/driver.ts | 26 +++-- .../src/server/itemPresentation.test.ts | 4 +- .../src/server/itemPresentation.ts | 2 +- .../src/server/maintenance.test.ts | 2 +- .../provider-muse/src/server/maintenance.ts | 0 .../src/server/modelCatalog.test.ts | 6 +- .../provider-muse/src/server/modelCatalog.ts | 5 +- .../provider-muse/src/server/protocol.test.ts | 2 +- .../provider-muse/src/server/protocol.ts | 0 .../provider-muse/src/server/sdk.test.ts | 2 +- .../provider-muse/src/server/sdk.ts | 0 .../provider-muse/src/server/status.test.ts | 8 +- .../provider-muse/src/server/status.ts | 9 +- .../src/server/textGeneration.test.ts | 8 +- .../src/server/textGeneration.ts | 96 +++++++++---------- packages/provider-muse/src/settings.ts | 36 +++++++ packages/provider-muse/src/testing.ts | 14 +++ packages/provider-muse/tsconfig.json | 7 ++ packages/provider-testing/package.json | 5 + .../provider-testing/src}/fakeCli.ts | 0 pnpm-lock.yaml | 46 +++++++++ scripts/release-smoke.ts | 1 + 44 files changed, 324 insertions(+), 194 deletions(-) create mode 100644 packages/provider-muse/package.json create mode 100644 packages/provider-muse/src/client.ts create mode 100644 packages/provider-muse/src/server.ts rename apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts => packages/provider-muse/src/server/adapter.test.ts (98%) rename apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts => packages/provider-muse/src/server/adapter.ts (98%) rename apps/server/src/provider/Drivers/MuseDriver.ts => packages/provider-muse/src/server/driver.ts (91%) rename apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts => packages/provider-muse/src/server/itemPresentation.test.ts (94%) rename apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts => packages/provider-muse/src/server/itemPresentation.ts (98%) rename apps/server/src/provider/museMaintenance.test.ts => packages/provider-muse/src/server/maintenance.test.ts (99%) rename apps/server/src/provider/museMaintenance.ts => packages/provider-muse/src/server/maintenance.ts (100%) rename apps/server/src/provider/museModelCatalog.test.ts => packages/provider-muse/src/server/modelCatalog.test.ts (96%) rename apps/server/src/provider/museModelCatalog.ts => packages/provider-muse/src/server/modelCatalog.ts (97%) rename apps/server/src/provider/museProtocol.test.ts => packages/provider-muse/src/server/protocol.test.ts (91%) rename apps/server/src/provider/museProtocol.ts => packages/provider-muse/src/server/protocol.ts (100%) rename apps/server/src/provider/museSdk.test.ts => packages/provider-muse/src/server/sdk.test.ts (99%) rename apps/server/src/provider/museSdk.ts => packages/provider-muse/src/server/sdk.ts (100%) rename apps/server/src/provider/MuseProvider.test.ts => packages/provider-muse/src/server/status.test.ts (96%) rename apps/server/src/provider/MuseProvider.ts => packages/provider-muse/src/server/status.ts (94%) rename apps/server/src/textGeneration/MuseTextGeneration.test.ts => packages/provider-muse/src/server/textGeneration.test.ts (98%) rename apps/server/src/textGeneration/MuseTextGeneration.ts => packages/provider-muse/src/server/textGeneration.ts (83%) create mode 100644 packages/provider-muse/src/settings.ts create mode 100644 packages/provider-muse/src/testing.ts create mode 100644 packages/provider-muse/tsconfig.json rename {apps/server/src/testUtils => packages/provider-testing/src}/fakeCli.ts (100%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 35fec0c2769a..08307ad65c2a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -70,6 +70,7 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 1675b15461d0..0645e6739818 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -120,18 +120,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "muse") { - return ( - - - - ); - } - const packageIcon = getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index c8c1c05c0158..2d3ec1224992 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,9 +1,10 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { museClient } from "@t3tools/provider-muse/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([piClient]); +const providerClients = makeProviderClientRegistry([museClient, piClient]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index 7672fbf534e3..bb776149e4cd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@opencode/protocol": "2.0.23", "@opencode/schema": "2.0.23", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/scripts/record-muse-msp-replay-fixture.ts b/apps/server/scripts/record-muse-msp-replay-fixture.ts index dde469487be0..a3cbfc6f9d14 100644 --- a/apps/server/scripts/record-muse-msp-replay-fixture.ts +++ b/apps/server/scripts/record-muse-msp-replay-fixture.ts @@ -33,8 +33,11 @@ import { MuseOrchestratorReplayHarness, } from "../src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { parseMuseVersion } from "../src/provider/museMaintenance.ts"; -import { makeMuseEnvironment, museServeArgs } from "../src/provider/museSdk.ts"; +import { + makeMuseEnvironment, + museServeArgs, + parseMuseVersion, +} from "@t3tools/provider-muse/testing"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index cce14853f6fd..f36d2c5959f5 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -16,33 +16,31 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Connection, type DuplexTransport } from "@muse-code/sdk"; import { - MuseSettings, ProviderDriverKind, ProviderInstanceId, ProviderReplayEntry, type ProviderReplayEntry as ProviderReplayEntryType, type ProviderReplayTranscript, } from "@t3tools/contracts"; +import { MuseSettings } from "@t3tools/provider-muse/settings"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { museInitializeParams, museServeArgs, type MuseSdkHost, type MuseSdkHostOptions, -} from "../../provider/museSdk.ts"; +} from "@t3tools/provider-muse/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; -import { makeMuseAdapterV2 } from "./MuseAdapterV2.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; export const MUSE_PROVIDER_KIND = "muse"; export const MUSE_MSP_REPLAY_PROTOCOL = "muse.msp-jsonl"; @@ -448,10 +446,6 @@ export function layer(input: { /** Replay needs none; a live recording passes the host environment. */ readonly environment?: NodeJS.ProcessEnv; }) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`muse-${input.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { return [ @@ -460,7 +454,7 @@ export function layer(input: { settings: DEFAULT_MUSE_SETTINGS, environment: input.environment ?? {}, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: input.createHost, // Same queue the continuation worker drains when the fixture runs it. @@ -468,7 +462,15 @@ export function layer(input: { }), ]; }), - ).pipe(Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer))); + ).pipe( + Layer.provide( + Layer.mergeAll( + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), + NodeServices.layer, + IdAllocator.layer, + ), + ), + ); } export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index f77ee10465e2..000176e9bb23 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -18,7 +18,7 @@ import { grokSlashCommandsFromInitialize, parseGrokModelsCliOutput, } from "./GrokProvider.ts"; -import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 08a403b70e04..1690a998c5f4 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -27,7 +27,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; -import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; +import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 46e015123df2..29c0f950f7c8 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -21,7 +21,7 @@ import { import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; -import { writeFakeCli } from "../testUtils/fakeCli.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const layerClaudeTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index fbfbe909f962..e991ddf17fdf 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -16,7 +16,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; -import { writeFakeCli } from "../testUtils/fakeCli.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index a577065913ab..8165683f62cf 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -16,7 +16,7 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; -import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); diff --git a/apps/web/package.json b/apps/web/package.json index ab2ae7b35ea3..63a5f5c2ad35 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 66e170dacf61..7f38d65f2a14 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -815,13 +815,3 @@ export const ComputerUseAppIcon: Icon = (props) => { ); }; - -export const MuseIcon: Icon = (props) => ( - - - -); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index ddfffd4fa9d3..e9e54575c910 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -8,7 +8,6 @@ import { ClaudeAI, CursorIcon, GrokIcon, - MuseIcon, Icon, OpenAI, OpenCodeIcon, @@ -29,7 +28,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, - [ProviderDriverKind.make("muse")]: MuseIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 59b42d0f6751..c967100888c8 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -6,10 +6,10 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, - MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { museClient } from "@t3tools/provider-muse/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this web build ships, in presentation order. */ @@ -53,12 +53,7 @@ export const providerClients = makeProviderClientRegistry([ label: "Antigravity", settingsSchema: AntigravitySettings, }, - { - driverKind: ProviderDriverKind.make("muse"), - label: "Muse Code", - settingsSchema: MuseSettings, - badgeLabel: "Beta", - }, + museClient, piClient, { driverKind: ProviderDriverKind.make("acpRegistry"), diff --git a/package.json b/package.json index eece70e89ef5..a345b307273d 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c9b3c00136dc..e62f4ff578b7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -840,29 +840,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; -export const MuseSettings = makeProviderSettingsSchema( - { - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("muse").pipe( - Schema.annotateKey({ - title: "Binary path", - description: - "Path to the Muse Code binary on this environment. Sign in with muse login on that host.", - providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { order: ["binaryPath"] }, -); -export type MuseSettings = typeof MuseSettings.Type; - export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; diff --git a/packages/provider-muse/package.json b/packages/provider-muse/package.json new file mode 100644 index 000000000000..a59c6cd20c8d --- /dev/null +++ b/packages/provider-muse/package.json @@ -0,0 +1,41 @@ +{ + "name": "@t3tools/provider-muse", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@muse-code/sdk": "1.4.2", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-muse/src/client.ts b/packages/provider-muse/src/client.ts new file mode 100644 index 000000000000..d709e8689395 --- /dev/null +++ b/packages/provider-muse/src/client.ts @@ -0,0 +1,27 @@ +/** + * Muse Code's client definition: settings form, label, badge, and glyph. + * Browser- and React Native-safe. + * + * @module provider-muse/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { MuseSettings } from "./settings.ts"; + +export const museClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("muse"), + label: "Muse Code", + settingsSchema: MuseSettings, + badgeLabel: "Beta", + icon: { + viewBox: "0 0 150.4828 100", + fill: { light: "#171717", dark: "#e5e5e5" }, + paths: [ + { + d: "M107.9999847,0.0000013c-12.3391113,0-21.984787,9.2935991-30.7164001,21.0993118 C65.2847366,5.821527,55.2499847,0,43.2413788,0C18.7586193,0,0,31.8620682,0,65.5862122 C0,86.6896515,10.2095623,100,27.3103466,100c12.3080502,0,21.1600361-5.8025894,36.8965454-33.3103409 c0,0,6.5597382-11.5841255,11.0725021-19.563858c1.5813522,2.5531845,3.2431107,5.3001404,4.9964676,8.2535095 l7.379303,12.4137955C102.0298843,91.8478851,110.039032,100,124.5517273,100 c16.6595764,0,25.9310303-13.4923325,25.9310303-35.0344849 C150.4827576,29.6551723,131.301178,0.0000013,107.9999847,0.0000013z M52.2068977,59.2413788 c-12.7586212,20-17.1724167,24.4827576-24.2758656,24.4827576c-7.3103428,0-11.6551704-6.4178314-11.6551704-17.8620682 c0-24.4827576,12.2068977-49.5172424,26.7586231-49.5172424c7.880127,0,14.4653816,4.5510426,24.5522308,18.9913158 C58.0087967,50.0272293,52.2068977,59.2413788,52.2068977,59.2413788z M100.3597336,56.7235756L91.5367355,42.00877 c-2.3876724-3.8832092-4.6757278-7.451458-6.8838348-10.7230263 c7.951973-12.273531,14.5113449-18.3891907,22.3126144-18.3891907c16.2069016,0,29.1724243,23.8620682,29.1724243,53.1724091 c0,11.1724167-3.6593475,17.6551743-11.2413864,17.6551743 C117.6294937,83.7241364,114.1578674,78.924736,100.3597336,56.7235756z", + fillRule: "evenodd", + }, + ], + }, +}); diff --git a/packages/provider-muse/src/server.ts b/packages/provider-muse/src/server.ts new file mode 100644 index 000000000000..7100802d7e13 --- /dev/null +++ b/packages/provider-muse/src/server.ts @@ -0,0 +1,8 @@ +/** + * Muse Code's server entry: the driver the server registers, plus the + * adapter factory the replay testkit and fixture recorder build on. + * + * @module provider-muse/server + */ +export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts"; +export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts b/packages/provider-muse/src/server/adapter.test.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts rename to packages/provider-muse/src/server/adapter.test.ts index 77e12726a183..59f8adec4026 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -5,7 +5,6 @@ import { MUSE_DEFAULT_MODEL, EnvironmentId, MessageId, - MuseSettings, NodeId, ProjectId, ProviderInstanceId, @@ -19,6 +18,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -31,10 +31,11 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as ServerConfig from "../../config.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import type { MuseItem } from "../../provider/museProtocol.ts"; -import type { MuseSdkHost } from "../../provider/museSdk.ts"; +import type { MuseItem } from "./protocol.ts"; +import type { MuseSdkHost } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy, @@ -42,14 +43,12 @@ import { type ProviderAdapterV2TurnInput, } from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; -import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; +import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; const testLayer = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-muse-v2-adapter-" }).pipe( - Layer.provide(NodeServices.layer), - ), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), ); const MUSE_PROVIDER = ProviderDriverKind.make("muse"); const INSTANCE_ID = ProviderInstanceId.make("muse_work"); @@ -205,7 +204,7 @@ const makeHarness = Effect.fnUntraced(function* ( settings: museSettings, environment: { PATH: "/fake/bin" }, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), ...overrides, @@ -437,7 +436,7 @@ describe("MuseAdapterV2", () => { settings: museSettings, environment: { PATH: "/fake/bin" }, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: async () => fake.host, }); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/packages/provider-muse/src/server/adapter.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts rename to packages/provider-muse/src/server/adapter.ts index 30d7073463a7..bf1699055534 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -1,7 +1,6 @@ import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; import { MUSE_DEFAULT_MODEL, - type MuseSettings, ProviderDriverKind, type ModelSelection, type OrchestrationV2ConversationMessage, @@ -22,6 +21,7 @@ import { type RuntimeRequestId, type ServerProviderModel, } from "@t3tools/contracts"; +import type { MuseSettings } from "../settings.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -35,14 +35,10 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; +import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import { - museModelCapabilities, - resolveMuseReasoningEffort, -} from "../../provider/museModelCatalog.ts"; +import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; import { MuseApproval, MuseCompactResult, @@ -60,14 +56,14 @@ import { museApprovalDecision, museApprovalOptions, type MuseItem, -} from "../../provider/museProtocol.ts"; +} from "./protocol.ts"; import { createMuseSdkHostEffect, museApprovalMode, type createMuseSdkHost, type MuseSdkHost, -} from "../../provider/museSdk.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; +} from "./sdk.ts"; +import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; import { providerMessageTextWithAttachmentPaths, isProviderNativeImageAttachment, @@ -103,7 +99,7 @@ import { import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; +import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; const MUSE_PROVIDER = ProviderDriverKind.make("muse"); const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); @@ -208,7 +204,7 @@ export interface MuseAdapterV2Options { readonly settings: MuseSettings; readonly environment: NodeJS.ProcessEnv; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHostShape; readonly fileSystem: FileSystem.FileSystem; readonly modelCatalog?: Effect.Effect>; readonly createHost?: typeof createMuseSdkHost; @@ -293,7 +289,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte const scope = yield* Effect.scope; // Muse rejects non-canonical workspace roots (for example macOS /tmp) and // reports canonical paths in approvals, so resolve symlinks once here. - const requestedCwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; + const requestedCwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; const cwd = yield* options.fileSystem .realPath(requestedCwd) .pipe(Effect.orElseSucceed(() => requestedCwd)); @@ -1601,19 +1597,12 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte const text = providerMessageTextWithAttachmentPaths({ text: message.text, attachments: message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: options.serverConfig.attachmentsDir, - attachment, - }), + resolveAttachmentPath: options.host.resolveAttachmentPath, }); if (text) parts.push({ type: "text", text }); for (const attachment of message.attachments) if (isProviderNativeImageAttachment(attachment)) { - const path = resolveAttachmentPath({ - attachmentsDir: options.serverConfig.attachmentsDir, - attachment, - }); + const path = options.host.resolveAttachmentPath(attachment); if (!path) return yield* protocolError("Muse image attachment is missing"); const bytes = yield* options.fileSystem .readFile(path) diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/packages/provider-muse/src/server/driver.ts similarity index 91% rename from apps/server/src/provider/Drivers/MuseDriver.ts rename to packages/provider-muse/src/server/driver.ts index 16bed1c3dab2..87f6b2818907 100644 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -1,4 +1,5 @@ -import { MuseSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,18 +9,17 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; +import { makeMuseTextGeneration } from "./textGeneration.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { makeMuseAdapterV2 } from "./adapter.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; +import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts"; -import { makeMuseEnvironment } from "../museSdk.ts"; +import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; +import { makeMuseEnvironment } from "./sdk.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -47,8 +47,7 @@ export type MuseDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderEventLoggers.ProviderEventLoggers; export const MuseDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -63,8 +62,7 @@ export const MuseDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const host = yield* ProviderHost; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; - const { cwd } = serverConfig; + const { cwd } = host.paths; const idAllocator = yield* IdAllocator.IdAllocatorV2; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const hostEnvironment = yield* HostProcessEnvironment; @@ -155,7 +153,7 @@ export const MuseDriver: ProviderDriver = { settings: effectiveConfig, environment: processEnvironment, idAllocator, - serverConfig, + host, fileSystem, modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), diff --git a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts b/packages/provider-muse/src/server/itemPresentation.test.ts similarity index 94% rename from apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts rename to packages/provider-muse/src/server/itemPresentation.test.ts index 7e4f489b5b00..59e48db95fd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts +++ b/packages/provider-muse/src/server/itemPresentation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import type { MuseItem } from "../../provider/museProtocol.ts"; -import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; +import type { MuseItem } from "./protocol.ts"; +import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; const item = (fields: Partial): MuseItem => ({ itemId: "native-item", diff --git a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts b/packages/provider-muse/src/server/itemPresentation.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts rename to packages/provider-muse/src/server/itemPresentation.ts index 93df7761a3eb..e05490698bef 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts +++ b/packages/provider-muse/src/server/itemPresentation.ts @@ -1,7 +1,7 @@ import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import type { MuseItem } from "../../provider/museProtocol.ts"; +import type { MuseItem } from "./protocol.ts"; const decodeToolArgs = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), diff --git a/apps/server/src/provider/museMaintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts similarity index 99% rename from apps/server/src/provider/museMaintenance.test.ts rename to packages/provider-muse/src/server/maintenance.test.ts index 59d0868e768d..1ac4a48cae60 100644 --- a/apps/server/src/provider/museMaintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -11,7 +11,7 @@ import { museMaintenance, latestMuseVersion, parseMuseVersion, -} from "./museMaintenance.ts"; +} from "./maintenance.ts"; import { ProviderVersionCache, makeManualOnlyProviderMaintenanceCapabilities, diff --git a/apps/server/src/provider/museMaintenance.ts b/packages/provider-muse/src/server/maintenance.ts similarity index 100% rename from apps/server/src/provider/museMaintenance.ts rename to packages/provider-muse/src/server/maintenance.ts diff --git a/apps/server/src/provider/museModelCatalog.test.ts b/packages/provider-muse/src/server/modelCatalog.test.ts similarity index 96% rename from apps/server/src/provider/museModelCatalog.test.ts rename to packages/provider-muse/src/server/modelCatalog.test.ts index 2679be85e853..628628a161bd 100644 --- a/apps/server/src/provider/museModelCatalog.test.ts +++ b/packages/provider-muse/src/server/modelCatalog.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; -import { MuseSettings } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import type { MuseSdkHost } from "./museSdk.ts"; +import type { MuseSdkHost } from "./sdk.ts"; import { discoverMuseModels, museModelCapabilities, resolveMuseReasoningEffort, -} from "./museModelCatalog.ts"; +} from "./modelCatalog.ts"; const settings = Schema.decodeSync(MuseSettings)({}); const host = (catalog: Record): MuseSdkHost => ({ diff --git a/apps/server/src/provider/museModelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts similarity index 97% rename from apps/server/src/provider/museModelCatalog.ts rename to packages/provider-muse/src/server/modelCatalog.ts index 7a75cf26f6d5..f390d062803c 100644 --- a/apps/server/src/provider/museModelCatalog.ts +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -1,9 +1,10 @@ import type { SendUserTurnOptions } from "@muse-code/sdk"; -import type { ModelCapabilities, MuseSettings, ServerProviderModel } from "@t3tools/contracts"; +import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./museSdk.ts"; +import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; type MuseReasoningEffort = NonNullable["reasoningEffort"]>; diff --git a/apps/server/src/provider/museProtocol.test.ts b/packages/provider-muse/src/server/protocol.test.ts similarity index 91% rename from apps/server/src/provider/museProtocol.test.ts rename to packages/provider-muse/src/server/protocol.test.ts index 479da19a4fdb..d583d84de495 100644 --- a/apps/server/src/provider/museProtocol.test.ts +++ b/packages/provider-muse/src/server/protocol.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { museApprovalChoices } from "./museProtocol.ts"; +import { museApprovalChoices } from "./protocol.ts"; // Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case. describe("Muse approval choices", () => { diff --git a/apps/server/src/provider/museProtocol.ts b/packages/provider-muse/src/server/protocol.ts similarity index 100% rename from apps/server/src/provider/museProtocol.ts rename to packages/provider-muse/src/server/protocol.ts diff --git a/apps/server/src/provider/museSdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts similarity index 99% rename from apps/server/src/provider/museSdk.test.ts rename to packages/provider-muse/src/server/sdk.test.ts index ff8de0c1abf9..8c92498ca517 100644 --- a/apps/server/src/provider/museSdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -9,7 +9,7 @@ import { makeMuseEnvironment, museApprovalMode, type MuseSdkHost, -} from "./museSdk.ts"; +} from "./sdk.ts"; vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); diff --git a/apps/server/src/provider/museSdk.ts b/packages/provider-muse/src/server/sdk.ts similarity index 100% rename from apps/server/src/provider/museSdk.ts rename to packages/provider-muse/src/server/sdk.ts diff --git a/apps/server/src/provider/MuseProvider.test.ts b/packages/provider-muse/src/server/status.test.ts similarity index 96% rename from apps/server/src/provider/MuseProvider.test.ts rename to packages/provider-muse/src/server/status.test.ts index be7cafdbd6a1..8f7031de0ad7 100644 --- a/apps/server/src/provider/MuseProvider.test.ts +++ b/packages/provider-muse/src/server/status.test.ts @@ -1,15 +1,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { MuseSettings } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; -import { writeFakeCli } from "../testUtils/fakeCli.ts"; -import type { MuseSdkHost } from "./museSdk.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; +import type { MuseSdkHost } from "./sdk.ts"; import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; -import { checkMuseProviderStatus } from "./MuseProvider.ts"; +import { checkMuseProviderStatus } from "./status.ts"; const settings = Schema.decodeSync(MuseSettings); const makeHost = (catalog: Record) => { diff --git a/apps/server/src/provider/MuseProvider.ts b/packages/provider-muse/src/server/status.ts similarity index 94% rename from apps/server/src/provider/MuseProvider.ts rename to packages/provider-muse/src/server/status.ts index 3d44431d0564..2cb4e5360ff2 100644 --- a/apps/server/src/provider/MuseProvider.ts +++ b/packages/provider-muse/src/server/status.ts @@ -1,4 +1,5 @@ -import type { MuseSettings, ServerProviderModel } from "@t3tools/contracts"; +import { ServerProviderModel } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -6,9 +7,9 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { ChildProcess } from "effect/process"; -import { createMuseSdkHost, makeMuseEnvironment } from "./museSdk.ts"; -import { parseMuseVersion } from "./museMaintenance.ts"; -import { discoverMuseModels, museModelCapabilities } from "./museModelCatalog.ts"; +import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; +import { parseMuseVersion } from "./maintenance.ts"; +import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, diff --git a/apps/server/src/textGeneration/MuseTextGeneration.test.ts b/packages/provider-muse/src/server/textGeneration.test.ts similarity index 98% rename from apps/server/src/textGeneration/MuseTextGeneration.test.ts rename to packages/provider-muse/src/server/textGeneration.test.ts index 5f3be0548705..41e84dae23ec 100644 --- a/apps/server/src/textGeneration/MuseTextGeneration.test.ts +++ b/packages/provider-muse/src/server/textGeneration.test.ts @@ -3,19 +3,19 @@ import { expect, it } from "@effect/vitest"; import type { NotificationHandler } from "@muse-code/sdk"; import { MUSE_DEFAULT_MODEL, - MuseSettings, ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; -import type { MuseSdkHost } from "../provider/museSdk.ts"; -import { museModelCapabilities } from "../provider/museModelCatalog.ts"; -import { makeMuseTextGeneration } from "./MuseTextGeneration.ts"; +import type { MuseSdkHost } from "./sdk.ts"; +import { museModelCapabilities } from "./modelCatalog.ts"; +import { makeMuseTextGeneration } from "./textGeneration.ts"; const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); const modelSelection = createModelSelection( diff --git a/apps/server/src/textGeneration/MuseTextGeneration.ts b/packages/provider-muse/src/server/textGeneration.ts similarity index 83% rename from apps/server/src/textGeneration/MuseTextGeneration.ts rename to packages/provider-muse/src/server/textGeneration.ts index e0d4f3f3afa5..8a13cf30260c 100644 --- a/apps/server/src/textGeneration/MuseTextGeneration.ts +++ b/packages/provider-muse/src/server/textGeneration.ts @@ -2,10 +2,10 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, MUSE_DEFAULT_MODEL, type ModelSelection, - type MuseSettings, type ServerProviderModel, TextGenerationError, } from "@t3tools/contracts"; +import type { MuseSettings } from "../settings.ts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; @@ -14,13 +14,9 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { - createMuseSdkHost, - createMuseSdkHostEffect, - type MuseSdkHost, -} from "../provider/museSdk.ts"; -import { museModelCapabilities, resolveMuseReasoningEffort } from "../provider/museModelCatalog.ts"; -import type * as TextGeneration from "./TextGeneration.ts"; +import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; +import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { buildBranchNamePrompt, buildCommitMessagePrompt, @@ -73,7 +69,7 @@ const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); -type Operation = keyof TextGeneration.TextGeneration["Service"]; +type Operation = keyof ProviderTextGeneration; async function generateMuseText( host: MuseSdkHost, @@ -299,52 +295,56 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi ); }); - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("MuseTextGeneration.generateCommitMessage")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateCommitMessage", - ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }), - modelSelection: input.modelSelection, - }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; + const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( + "MuseTextGeneration.generateCommitMessage", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateCommitMessage", + ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }), + modelSelection: input.modelSelection, }); - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("MuseTextGeneration.generatePrContent")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generatePrContent", - ...buildPrContentPrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn( + "MuseTextGeneration.generatePrContent", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generatePrContent", + ...buildPrContentPrompt(input), + modelSelection: input.modelSelection, }); - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("MuseTextGeneration.generateBranchName")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateBranchName", - ...buildBranchNamePrompt(input), - modelSelection: input.modelSelection, - }); - return { branch: sanitizeBranchFragment(generated.branch) }; + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn( + "MuseTextGeneration.generateBranchName", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateBranchName", + ...buildBranchNamePrompt(input), + modelSelection: input.modelSelection, }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("MuseTextGeneration.generateThreadTitle")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateThreadTitle", - ...buildThreadTitlePrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizeThreadTitle(generated.title) }; + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn( + "MuseTextGeneration.generateThreadTitle", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateThreadTitle", + ...buildThreadTitlePrompt(input), + modelSelection: input.modelSelection, }); + return { title: sanitizeThreadTitle(generated.title) }; + }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; + } satisfies ProviderTextGeneration; }); diff --git a/packages/provider-muse/src/settings.ts b/packages/provider-muse/src/settings.ts new file mode 100644 index 000000000000..85a781dc6608 --- /dev/null +++ b/packages/provider-muse/src/settings.ts @@ -0,0 +1,36 @@ +/** + * Muse Code instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-muse/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const MuseSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("muse").pipe( + Schema.annotateKey({ + title: "Binary path", + description: + "Path to the Muse Code binary on this environment. Sign in with muse login on that host.", + providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath"] }, +); +export type MuseSettings = typeof MuseSettings.Type; diff --git a/packages/provider-muse/src/testing.ts b/packages/provider-muse/src/testing.ts new file mode 100644 index 000000000000..810388c5afe9 --- /dev/null +++ b/packages/provider-muse/src/testing.ts @@ -0,0 +1,14 @@ +/** + * Muse Code internals for replay tests and the fixture recorder: the MSP + * launch arguments, initialize parameters, environment, and version parser. + * + * @module provider-muse/testing + */ +export { + makeMuseEnvironment, + museInitializeParams, + museServeArgs, + type MuseSdkHost, + type MuseSdkHostOptions, +} from "./server/sdk.ts"; +export { parseMuseVersion } from "./server/maintenance.ts"; diff --git a/packages/provider-muse/tsconfig.json b/packages/provider-muse/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-muse/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-testing/package.json b/packages/provider-testing/package.json index 35e3941ba87d..39c6c1f644aa 100644 --- a/packages/provider-testing/package.json +++ b/packages/provider-testing/package.json @@ -18,6 +18,10 @@ "./host": { "types": "./src/host.ts", "import": "./src/host.ts" + }, + "./fakeCli": { + "types": "./src/fakeCli.ts", + "import": "./src/fakeCli.ts" } }, "scripts": { @@ -28,6 +32,7 @@ "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/apps/server/src/testUtils/fakeCli.ts b/packages/provider-testing/src/fakeCli.ts similarity index 100% rename from apps/server/src/testUtils/fakeCli.ts rename to packages/provider-testing/src/fakeCli.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edc6c3e9a99a..0a4d57f3c47a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -603,6 +606,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -733,6 +739,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -1099,6 +1108,40 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-muse: + dependencies: + '@muse-code/sdk': + specifier: 1.4.2 + version: 1.4.2(@types/node@24.12.4) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-pi: dependencies: '@t3tools/contracts': @@ -1141,6 +1184,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.1 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 8fb9ab883995..2f39d7a61e93 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-muse/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", From 6308db4ff500b89821e22605f964b1701cead8ee Mon Sep 17 00:00:00 2001 From: Daniel Alvim Date: Thu, 8 Oct 2026 18:50:57 -0500 Subject: [PATCH 037/377] fix(web): semantic branch naming hint lines up with its setting (#16972) --- .../src/components/settings/BranchNamingSettings.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/BranchNamingSettings.tsx b/apps/web/src/components/settings/BranchNamingSettings.tsx index d6167d0934a4..d5365e8c912b 100644 --- a/apps/web/src/components/settings/BranchNamingSettings.tsx +++ b/apps/web/src/components/settings/BranchNamingSettings.tsx @@ -37,6 +37,11 @@ export function BranchNamingSettings() { settingKeys={["branchNamingMode"]} {...searchableSetting("worktree-branch-naming")} description="Choose how new worktree branches are named from your first message." + status={ + !modeMixed && settings.branchNamingMode === "semantic" + ? "The model chooses a prefix that describes the work, such as feat/add-search, fix/login-timeout, or refactor/auth." + : undefined + } resetAction={ settings.branchNamingMode !== DEFAULT_SERVER_SETTINGS.branchNamingMode || modeMixed ? ( ) : null} - {!modeMixed && settings.branchNamingMode === "semantic" ? ( -

- The model chooses a prefix that describes the work, such as feat/add-search, - fix/login-timeout, or refactor/auth. -

- ) : null} {!modeMixed && settings.branchNamingMode === "custom" ? ( Date: Thu, 8 Oct 2026 16:59:32 -0700 Subject: [PATCH 038/377] fix(mobile): restore chat image previews in the v5 stack (#17361) --- .../createWorkspaceStackNavigator.ios.tsx | 1 + .../src/native/createV5StackNavigator.ios.tsx | 1 + ...act-navigation%2Fnative-stack@7.17.6.patch | 150 +++++++++++++++++- pnpm-lock.yaml | 6 +- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index c8104f9f4b1a..e642754a013b 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -247,6 +247,7 @@ function WorkspaceStackView(props: ViewProps) { const routes = [baseRoute, ...overlays.map((group) => group[0]!)]; return ( { const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; const isFocused = state.index === index; +@@ -386,6 +403,7 @@ export function NativeStackView({ + // due to component being unmounted. To avoid this, we don't freeze the previous screen there + const shouldFreeze = isFabric() ? !isPreloaded && !isFocused && !isBelowFocused && !isModalOnIos : !isPreloaded && !isFocused && !isModalOnIos; + return /*#__PURE__*/_jsx(SceneView, { ++ presentationEnvelope, + index: index, + focused: isFocused, + shouldFreeze: shouldFreeze, diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d6f55b11e 100644 --- a/lib/module/views/useHeaderConfigProps.js @@ -189,3 +228,110 @@ index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51 > {item.element} +diff --git a/lib/typescript/src/views/NativeStackView.native.d.ts b/lib/typescript/src/views/NativeStackView.native.d.ts +index c0d37db3b4a2a4e9340c76333821f44a87cd624b..eb3db8f511799b335a17edea31a2acd17627f909 100644 +--- a/lib/typescript/src/views/NativeStackView.native.d.ts ++++ b/lib/typescript/src/views/NativeStackView.native.d.ts +@@ -1,11 +1,12 @@ + import { type ParamListBase, type RouteProp, type StackNavigationState } from '@react-navigation/native'; + import type { NativeStackDescriptor, NativeStackDescriptorMap, NativeStackNavigationHelpers } from '../types.js'; + type Props = { ++ presentationEnvelope?: boolean; + state: StackNavigationState; + navigation: NativeStackNavigationHelpers; + descriptors: NativeStackDescriptorMap; + describe: (route: RouteProp, placeholder: boolean) => NativeStackDescriptor; + }; +-export declare function NativeStackView({ state, navigation, descriptors, describe, }: Props): import("react/jsx-runtime").JSX.Element; ++export declare function NativeStackView({ presentationEnvelope, state, navigation, descriptors, describe, }: Props): import("react/jsx-runtime").JSX.Element; + export {}; + //# sourceMappingURL=NativeStackView.native.d.ts.map +\ No newline at end of file +diff --git a/src/views/NativeStackView.native.tsx b/src/views/NativeStackView.native.tsx +index 416cd90057cea362371a4308dd9985b086c5ccaa..7188a3daf0af2004303b21f89d3c0c7073517ef8 100644 +--- a/src/views/NativeStackView.native.tsx ++++ b/src/views/NativeStackView.native.tsx +@@ -51,7 +51,14 @@ function isFabric() { + return 'nativeFabricUIManager' in global; + } + ++// A presentation envelope contains descriptors from its owning navigator. ++// Its synthetic route must not override those descriptors' focus state. ++function SceneNavigationProvider({ presentationEnvelope, ...props }: React.ComponentProps & { presentationEnvelope?: boolean }) { ++ return presentationEnvelope ? props.children : ; ++} ++ + type SceneViewProps = { ++ presentationEnvelope?: boolean; + index: number; + focused: boolean; + shouldFreeze: boolean; +@@ -74,6 +81,7 @@ type SceneViewProps = { + const useNativeDriver = Platform.OS !== 'web'; + + const SceneView = ({ ++ presentationEnvelope, + index, + focused, + shouldFreeze, +@@ -356,7 +364,7 @@ const SceneView = ({ + ); + + return ( +- ++ + + + +- ++ + ); + }; + + type Props = { ++ presentationEnvelope?: boolean; + state: StackNavigationState; + navigation: NativeStackNavigationHelpers; + descriptors: NativeStackDescriptorMap; +@@ -508,6 +517,7 @@ type Props = { + }; + + export function NativeStackView({ ++ presentationEnvelope, + state, + navigation, + descriptors, +@@ -555,6 +565,7 @@ export function NativeStackView({ + + return ( + ; + navigation: NativeStackNavigationHelpers; + descriptors: NativeStackDescriptorMap; +diff --git a/src/views/NativeStackView.tsx b/src/views/NativeStackView.tsx +--- a/src/views/NativeStackView.tsx ++++ b/src/views/NativeStackView.tsx +@@ -24,6 +24,7 @@ + import { AnimatedHeaderHeightContext } from '../utils/useAnimatedHeaderHeight'; + + type Props = { ++ presentationEnvelope?: boolean; + state: StackNavigationState; + // This is used for the native implementation of the stack. + navigation: NativeStackNavigationHelpers; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4d57f3c47a..4502702d9c19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,7 @@ patchedDependencies: '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba - '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 + '@react-navigation/native-stack@7.17.6': 3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.1: a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f @@ -325,7 +325,7 @@ importers: version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06) + version: 7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -15420,7 +15420,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(141a742cd91f55378cde709a48a3af06)': + '@react-navigation/native-stack@7.17.6(patch_hash=3a53ec9b92f5db5f083f3ada585da75d62a13c82250c6ae47d92be91a828ad87)(141a742cd91f55378cde709a48a3af06)': dependencies: '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be) '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) From c724c7cb02aaeb0a23ff6e5e224d17c0fdb9a36b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 17:07:44 -0700 Subject: [PATCH 039/377] feat(mobile): fade working threads and match web's status labels (#17368) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/components/AppSymbol.tsx | 6 + .../features/threads/thread-list-v2-items.tsx | 116 ++++++++++++++---- .../threads/thread-row-emphasis.test.ts | 20 +++ .../features/threads/thread-row-emphasis.ts | 13 ++ 4 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-row-emphasis.test.ts create mode 100644 apps/mobile/src/features/threads/thread-row-emphasis.ts diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 9e9a6e1173c7..122ad1658e27 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -37,6 +37,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; import IconCircle from "@tabler/icons-react-native/IconCircle"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; +import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; @@ -75,6 +76,7 @@ import IconMicrophone from "@tabler/icons-react-native/IconMicrophone"; import IconLink from "@tabler/icons-react-native/IconLink"; import IconListNumbers from "@tabler/icons-react-native/IconListNumbers"; import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; +import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion"; import IconMessage from "@tabler/icons-react-native/IconMessage"; import IconMinus from "@tabler/icons-react-native/IconMinus"; import IconMoon from "@tabler/icons-react-native/IconMoon"; @@ -98,6 +100,7 @@ import IconStar from "@tabler/icons-react-native/IconStar"; import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; +import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion"; import IconTarget from "@tabler/icons-react-native/IconTarget"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -148,6 +151,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, circle: IconCircle, + "circle.dashed": IconCircleDashed, clock: IconClock, timer: IconClock, ticket: IconTicket, @@ -221,6 +225,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = { terminal: IconTerminal2, "text.alignleft": IconAlignLeft, "text.bubble": IconMessage, + "questionmark.bubble": IconMessageCircleQuestion, + "exclamationmark.shield": IconShieldQuestion, "text.word.spacing": IconLetterSpacing, "textformat.size": IconTypography, "textformat.size.larger": IconTextIncrease, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 4bc4328a5784..88ad0d43ea65 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,7 +25,7 @@ import { Alert, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import type { ThreadListProvider } from "../../state/thread-list-environments"; -import { SymbolView } from "../../components/AppSymbol"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -51,6 +51,7 @@ import { type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; +import { shouldRecedeThreadRow } from "./thread-row-emphasis"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** @@ -63,14 +64,50 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; // Status hues follow the system-wide convention set by sidebar v1 and the // Live Activity/widgets (amber approval, indigo input, sky working) so a // thread reads the same color everywhere it surfaces. -const STATUS_LABEL_BY_STATUS: Partial< - Record -> = { - approval: { label: "Approval", className: "text-warning-foreground" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-danger-foreground" }, - limited: { label: "Limited", className: "text-warning-foreground" }, +// Icons match the web sidebar's status glyphs. +interface StatusLabel { + readonly label: string; + readonly icon: AppSymbolName; + readonly className: string; + readonly iconTintClassName: string; +} +const STATUS_LABEL_BY_STATUS: Partial> = { + approval: { + label: "Approval", + icon: "exclamationmark.shield", + className: "text-warning-foreground", + iconTintClassName: "accent-warning-foreground", + }, + input: { + label: "Input", + icon: "questionmark.bubble", + className: "text-adaptive-indigo-600-300", + iconTintClassName: "accent-adaptive-indigo-600-300", + }, + working: { + label: "Working", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, + failed: { + label: "Failed", + icon: "exclamationmark.circle", + className: "text-danger-foreground", + iconTintClassName: "accent-danger-foreground", + }, + limited: { + label: "Limited", + icon: "exclamationmark.circle", + className: "text-warning-foreground", + iconTintClassName: "accent-warning-foreground", + }, +}; +const DONE_STATUS_LABEL: StatusLabel = { + label: "Done", + icon: "checkmark.circle", + className: "text-adaptive-emerald-700-300", + iconTintClassName: "accent-adaptive-emerald-700-300", }; // Menus keep lifecycle and title regeneration together. Archive keeps its @@ -593,8 +630,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // A native /goal keeps the agent going across turns until it is met. (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } - : workingLabel) ?? - (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined); + : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + const recede = shouldRecedeThreadRow({ status, selected }); // The timestamp is precomputed on the list item (same stamps the settled // tail sorts by) so a minute tick only re-renders rows that draw it. const timeLabel = props.timeLabel; @@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { type="monochrome" /> ) : null} - + + + {statusLabel.label} + + + ) : ( + - {statusLabel?.label ?? timeLabel} - + : rowAppearance.tertiaryForegroundClassName, + )} + > + {timeLabel} + + )} ); + // Background work fades as a whole, status label included, so it takes + // less attention than rows that need a human (input, approval, done). + const fadedCardContent = recede ? ( + {cardContent} + ) : ( + cardContent + ); + const rowContent = (close: () => void) => variant === "card" ? ( {sidebarPane ? ( - cardContent + fadedCardContent ) : ( /* Flat native list rows: no tonal containers — colored status labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - {cardContent} + {fadedCardContent} {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( ) : null} diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.test.ts b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts new file mode 100644 index 000000000000..1ac97f0781c8 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldRecedeThreadRow } from "./thread-row-emphasis"; + +describe("shouldRecedeThreadRow", () => { + it("recedes background work", () => { + expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true); + expect(shouldRecedeThreadRow({ status: "waiting", selected: false })).toBe(true); + }); + + it("keeps rows that need a human at full strength", () => { + for (const status of ["approval", "input", "failed", "limited", "ready"] as const) { + expect(shouldRecedeThreadRow({ status, selected: false })).toBe(false); + } + }); + + it("never fades the selected row", () => { + expect(shouldRecedeThreadRow({ status: "working", selected: true })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.ts b/apps/mobile/src/features/threads/thread-row-emphasis.ts new file mode 100644 index 000000000000..a861ef42a4e7 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-row-emphasis.ts @@ -0,0 +1,13 @@ +import type { ThreadListV2Status } from "./threadListV2"; + +/** + * Mirrors web's recede rule: background work fades so rows that need a + * human (approval, input, failure, unseen completion) stand out. An unseen + * completion cannot pull a still-working thread forward. + */ +export function shouldRecedeThreadRow(input: { + readonly status: ThreadListV2Status; + readonly selected: boolean; +}): boolean { + return !input.selected && (input.status === "working" || input.status === "waiting"); +} From dbd83435347d13d5f456271f4f1f5efe294a111d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 17:12:28 -0700 Subject: [PATCH 040/377] fix(server): agent browser tools stop bloating history, fall back sensibly, and respect ownership (#16956) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/mcp/McpDeviceToolkit.test.ts | 23 +- apps/server/src/mcp/McpHttpServer.test.ts | 23 +- apps/server/src/mcp/McpHttpServer.ts | 15 +- .../src/mcp/toolkits/attachment/handlers.ts | 150 ++++++++----- apps/server/src/mcp/toolkits/core.test.ts | 60 +++++ .../src/mcp/toolkits/device/handlers.ts | 19 +- apps/server/src/mcp/toolkits/preview/tools.ts | 17 +- .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/preview/ServerBrowser.test.ts | 206 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 170 +++++++++++++-- .../src/preview/ServerBrowserStream.test.ts | 5 + .../server/src/preview/SessionControl.test.ts | 12 + apps/server/src/preview/SessionControl.ts | 27 ++- apps/server/src/ws.ts | 1 + apps/web/src/AppRoot.tsx | 2 + .../src/browser/BrowserProfileReporter.tsx | 40 ++++ apps/web/src/browser/ElectronBrowserHost.tsx | 24 +- packages/client-runtime/src/state/preview.ts | 5 + packages/contracts/src/preview.ts | 10 +- packages/contracts/src/previewAutomation.ts | 38 +++- packages/contracts/src/rpc.ts | 8 + .../src/server/orchestrationInstructions.ts | 7 +- 24 files changed, 743 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/browser/BrowserProfileReporter.tsx diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 97a8f8dc0424..0359b07b3941 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -152,6 +152,7 @@ describe("RPC authorization scopes", () => { WS_METHODS.previewReportStatus, WS_METHODS.previewAdjust, WS_METHODS.previewClearProfile, + WS_METHODS.previewReportProfiles, ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5f242c439927..a33d2f3317aa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewClose]: AuthPreviewOperateScope, [WS_METHODS.previewList]: AuthOrchestrationReadScope, [WS_METHODS.previewClearProfile]: AuthPreviewOperateScope, + [WS_METHODS.previewReportProfiles]: AuthPreviewOperateScope, [WS_METHODS.previewReportStatus]: AuthPreviewOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 3c2f4e5905eb..a77e8f600252 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DeviceHostUnavailableError, + DeviceId, EnvironmentId, ProviderInstanceId, ThreadId, @@ -93,7 +94,21 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ platform: input.platform, openedAt: "2026-09-08T00:00:00.000Z", }), - sessionsForThread: () => Effect.succeed([]), + // UDID-1 is open in the test thread; UDID-2 exists but belongs to another thread. + sessionsForThread: (id) => + Effect.succeed( + id === threadId + ? [ + { + threadId, + hostId: "local", + deviceId: DeviceId.make("UDID-1"), + platform: "ios" as const, + openedAt: "2026-09-08T00:00:00.000Z", + }, + ] + : [], + ), screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, agentCli: Effect.succeed("/cli"), @@ -135,6 +150,12 @@ it.effect("registers the device tools and returns the screenshot as image conten screenshot: { mimeType: "image/png", width: 1206, height: 2622 }, }); + const foreign = yield* server + .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-2" } }) + .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(foreign.isError).toBe(true); + expect(foreign.content.map((entry) => entry.type)).toEqual(["text"]); + const denied = yield* server .callTool({ name: "device_list", arguments: {} }) .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 3505e4e58633..fb9ca151d360 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -244,7 +244,7 @@ it.effect("tells the agent how to fall back when no desktop app can run the snap ); it.effect.each([ - { mode: "default", input: {}, images: true }, + { mode: "default", input: {}, images: false }, { mode: "explicit image", input: { includeImage: true }, images: true }, { mode: "text only", input: { includeImage: false }, images: false }, ])("returns fresh $mode snapshots on repeated MCP calls", ({ input, images }) => @@ -351,12 +351,7 @@ it.effect.each([ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(nextDefault.content.map((content) => content.type)).toEqual([ - "text", - "text", - "text", - "image", - ]); + expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "text"]); expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot }); expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree"); expect(requests).toBe(7); @@ -416,11 +411,12 @@ it.effect("saves the snapshot PNG on request and reports its path", () => const path = yield* Path.Path; const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult); - const snapshot = yield* callSnapshot({ save: true }); + const snapshot = yield* callSnapshot({ save: true, includeImage: true }); expect(snapshot.isError).toBe(false); // The browser never receives the server-only `save` flag. expect(inputs).toEqual([{}]); + expect(snapshot.content.map((content) => content.type)).toContain("image"); const structured = snapshot.structuredContent as { readonly screenshotPath?: string }; const screenshotPath = structured.screenshotPath; expect(typeof screenshotPath).toBe("string"); @@ -436,7 +432,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); // A save without the image skips the page dump. - const pathOnly = yield* callSnapshot({ save: true, includeImage: false }); + const pathOnly = yield* callSnapshot({ save: true }); const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png"); @@ -851,8 +847,8 @@ it.effect("registers annotated tools and preserves authenticated request context expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); - expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); + expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(false); + expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(false); expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); @@ -891,7 +887,10 @@ it.effect("registers annotated tools and preserves authenticated request context expect(malformed._tag).toBe("InvalidParams"); const snapshot = yield* server - .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } }) + .callTool({ + name: "preview_snapshot", + arguments: { tabId: alternateTabId, includeImage: true }, + }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index b63cd7b95d7e..f4da6e051a7c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -503,7 +503,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; - if (screenshotPath !== undefined && payload?.includeImage === false) { + // Images stay out of tool history unless asked for: providers replay them on every + // later request, and some reject inline images outright. + const includeImage = payload?.includeImage === true; + if (screenshotPath !== undefined && !includeImage) { // The agent only wants a file to show the user. The url keeps the site icon on the tool row. const saved = { url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), @@ -548,9 +551,9 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`, }, ]), - ...(payload?.includeImage === false - ? [] - : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]), + ...(includeImage + ? [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }] + : []), ], }); }), @@ -815,7 +818,7 @@ const layerEnvironmentRegistration = toolkitRegistration( const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); -const layerAttachmentRegistration = toolkitRegistration( +export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, AttachmentHandlers.layer, ); @@ -851,7 +854,7 @@ export const layer = Layer.mergeAll( layerPreviewToolkit, layerOrchestratorToolkit, layerThreadToolkit, - layerAttachmentRegistration, + layerAttachmentToolkit, layerProjectRegistration, layerEnvironmentRegistration, layerPreviewControlsRegistration, diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 9b9a6015d232..2236d8f305de 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -1,5 +1,12 @@ -import { type ChatAttachment, MessageId, OrchestratorMcpFailure } from "@t3tools/contracts"; +import { + ATTACHMENT_UPLOAD_URL_TTL_MS, + type ChatAttachment, + MessageId, + OrchestratorMcpFailure, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; @@ -27,53 +34,94 @@ export function resolveAttachmentReferences( }); } -export const layer = McpToolAccess.toLayer(AttachmentToolkit, { - t3_attachment_prepare_upload: McpToolAccess.writes((input) => - Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)), - ), - t3_attachment_discard: McpToolAccess.writes((input) => - Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})), - ), - t3_thread_send_attachments: McpToolAccess.writesThreads( - (input) => [input.threadId], - (input) => - Effect.gen(function* () { - const { caller, projection } = yield* readThread(input.threadId, ["messages"]); - if (projection.thread.archivedAt !== null) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Unarchive the target thread before sending attachments.", - }); - const attachments = yield* resolveAttachmentReferences( - input.attachments, - projection.messages.flatMap((message) => message.attachments), - ); - const commandId = yield* newCommandId(); - const messageId = MessageId.make(commandId); - const result = yield* ThreadMessageIntake.sendToThread({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - commandId, - messageId, - ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", - attachments, - mode: "auto", - createdBy: "agent", - creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); - return { - threadId: projection.thread.id, - messageId, - runId: result.run.id, - status: result.run.status, - }; - }), - ), -}); +/** + * As long as the pending file can live. The sweep counts its 24 hours from when + * the upload finishes, which can be up to the upload URL's lifetime after issue. + */ +const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; + +/** A thread owns its uploads across provider sessions; an outside client per MCP session. */ +const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( + Effect.map((scope) => scope.thread?.threadId ?? scope.requestNamespace), +); + +export const layer = McpToolAccess.toLayer( + AttachmentToolkit, + Effect.sync(() => { + // Which caller prepared each pending upload, so only that caller can discard it. + const uploadOwners = new Map(); + return { + t3_attachment_prepare_upload: McpToolAccess.writes((input) => + Effect.gen(function* () { + const result = yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( + Effect.mapError(unavailable), + ); + const now = yield* Clock.currentTimeMillis; + for (const [id, entry] of uploadOwners) { + if (now - entry.issuedAt > UPLOAD_OWNER_TTL_MS) uploadOwners.delete(id); + } + uploadOwners.set(result.attachmentId, { owner: yield* uploadOwner, issuedAt: now }); + return result; + }), + ), + t3_attachment_discard: McpToolAccess.writes((input) => + Effect.gen(function* () { + if (uploadOwners.get(input.attachmentId)?.owner !== (yield* uploadOwner)) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Only the caller that prepared a pending upload can discard it.", + }); + } + yield* Upload.deletePendingAttachment(input.attachmentId); + uploadOwners.delete(input.attachmentId); + return {}; + }), + ), + t3_thread_send_attachments: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const { caller, projection } = yield* readThread(input.threadId, ["messages"]); + if (projection.thread.archivedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Unarchive the target thread before sending attachments.", + }); + const attachments = yield* resolveAttachmentReferences( + input.attachments, + projection.messages.flatMap((message) => message.attachments), + ); + const commandId = yield* newCommandId(); + const messageId = MessageId.make(commandId); + const result = yield* ThreadMessageIntake.sendToThread({ + projectId: projection.thread.projectId, + threadId: projection.thread.id, + commandId, + messageId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), + text: input.message ?? "", + attachments, + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }).pipe( + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }) + : unavailable(), + ), + ); + return { + threadId: projection.thread.id, + messageId, + runId: result.run.id, + status: result.run.status, + }; + }), + ), + }; + }), +); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 634133b62e9e..9380a8d7af90 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -14,6 +14,7 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpAttachmentInput } from "./attachment/input.ts"; @@ -26,6 +27,7 @@ import { OrchestratorProjectionError, } from "../../orchestration-v2/Orchestrator.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; @@ -696,3 +698,61 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () ), ), ); + +it.effect("only the caller that prepared a pending upload can discard it", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = ( + name: string, + args: Record, + invocation: McpInvocationContext.McpInvocationScope, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const prepared = yield* call( + "t3_attachment_prepare_upload", + { upload: { name: "shot.png", mimeType: "image/png", sizeBytes: 4 } }, + scope, + ); + const { attachmentId } = prepared.structuredContent as { readonly attachmentId: string }; + const otherThread = { + ...scope, + requestNamespace: "other-session", + thread: { ...scope.thread!, threadId: ThreadId.make("other-thread") }, + }; + + const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread); + expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" }); + + // Another prepare a day later keeps it: the file outlives its URL's 24 hours + // by as long as the upload took, up to the URL's own lifetime. + yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000); + yield* call( + "t3_attachment_prepare_upload", + { upload: { name: "later.png", mimeType: "image/png", sizeBytes: 4 } }, + scope, + ); + + // A new provider session of the same thread still owns the upload. + const discarded = yield* call( + "t3_attachment_discard", + { attachmentId }, + { ...scope, requestNamespace: "mcp-core-session-2" }, + ); + expect(discarded.isError).toBe(false); + }).pipe( + Effect.provide( + McpHttpServer.layerAttachmentToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })), + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index bf697229718c..1543bbc87100 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -211,15 +211,20 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; const sessions = yield* devices.sessionsForThread(scope.thread.threadId); - const target = - input.deviceId !== undefined - ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } - : sessions - .filter((session) => input.hostId === undefined || session.hostId === input.hostId) - .at(-1); + // Only devices this thread opened: another thread's device is not this agent's to watch. + const hostId = + input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID); + const target = sessions.findLast( + (session) => + (hostId === undefined || session.hostId === hostId) && + (input.deviceId === undefined || session.deviceId === input.deviceId), + ); if (!target) { return yield* new DeviceToolUnavailableError({ - reason: "No device is open in this thread. Call device_open first.", + reason: + input.deviceId === undefined + ? "No device is open in this thread. Call device_open first." + : `Device ${input.deviceId} on host ${hostId} is not open in this thread. Call device_open first.`, }); } const shot = yield* devices.screenshot(target); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 9c94a1d446c6..6e34a330025e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -64,7 +64,7 @@ const readonlyBrowserTool = (tool: T): T => const PreviewStatusTool = Tool.make("preview_status", { description: - "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", + "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Server hosts also list every tab in the thread (tabs) with its owner, including tabs the user opened, and the browser profiles preview_open accepts. You can read any listed tab; act only on your own tabs, or on an unclaimed tab while no human controls it.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, @@ -78,7 +78,7 @@ const PreviewStatusTool = Tool.make("preview_status", { const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: - "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.", + "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Pass profileId (an id or name from preview_status profiles) to open under a browser profile and its saved logins; omit it for the user's default profile. Another agent session's tabs can be read but not operated.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, @@ -91,7 +91,7 @@ const PreviewOpenTool = browserTool( const PreviewDialogTool = browserTool( Tool.make("preview_dialog", { description: - "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.", + "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab, or the tab to be unclaimed with no human in control. Desktop hosts may not support this operation.", parameters: PreviewAutomationDialogInput, success: PreviewAutomationStatus, failure: PreviewToolFailure, @@ -139,22 +139,23 @@ const PreviewSetAppearanceTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); -export const PreviewSnapshotTool = readonlyBrowserTool( +// Not read-only: save=true writes the screenshot to disk. +export const PreviewSnapshotTool = safeBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Server snapshots include an accessibilityTree with refs; pass locator=aria-ref= to target one exact element, including inside frames. Refresh refs after navigation, another snapshot, or human takeover. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", + "Inspect a page before interacting. Pass tabId to inspect a specific tab, including one the user opened (see preview_status tabs), even while they control it; omit it to use this agent session's current tab, or the tab the user is viewing when you have none. Returns page state, semantic elements, diagnostics, action history, and screenshot dimensions. Server snapshots include an accessibilityTree with refs; pass locator=aria-ref= to target one exact element, including inside frames. Refresh refs after navigation, another snapshot, or human takeover. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=true only when you need to see the page; the image stays in tool history. Set save=true to write the PNG to disk and get back only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", parameters: Schema.Struct({ ...PreviewAutomationTabTargetInput.fields, includeImage: Schema.optional( Schema.Boolean.annotate({ description: - "Include the PNG image in the tool response. Defaults to true. Set false for text-only output.", + "Include the PNG image in the tool response. Defaults to false. Set true only when you need to see the page.", }), ), save: Schema.optional( Schema.Boolean.annotate({ description: - "Write the screenshot PNG to disk and return its absolute path as screenshotPath. With includeImage=false, return only the url and screenshotPath. Defaults to false.", + "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Unless includeImage=true, return only the url and screenshotPath. Defaults to false.", }), ), }), @@ -270,7 +271,7 @@ export const PreviewEvaluateResult = Schema.Struct({ const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.", + "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Needs the same control as clicking: it works on your own tabs and on the user's tab only while nobody controls it. To read a tab the user is driving, use preview_snapshot. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: PreviewEvaluateResult, failure: PreviewToolFailure, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 9aac9be1a56b..b1abaf8d465c 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -183,6 +183,7 @@ const RPC_AGGREGATES = { [WS_METHODS.previewClose]: "preview", [WS_METHODS.previewList]: "preview", [WS_METHODS.previewClearProfile]: "preview", + [WS_METHODS.previewReportProfiles]: "preview", [WS_METHODS.previewReportStatus]: "preview", [WS_METHODS.subscribePreviewEvents]: "preview", [WS_METHODS.subscribeDiscoveredLocalServers]: "preview", diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 45a2167ca042..f66e255c51b4 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -38,7 +38,8 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ constructor(options: { onContextClose?: (context: BrowserContext) => void }) { this.onClose = options.onContextClose; } - async contextFor() { + async contextFor(profileId: string, isolationKey?: string) { + contextRequests.push({ profileId, isolated: isolationKey !== undefined }); if (contextFailure) throw contextFailure; await contextGate?.promise; const context = makeContext(this.onClose); @@ -149,6 +150,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { } const contexts: ReturnType[] = []; +/** The profile and isolation each headless tab asked its context for. */ +const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; type ClipboardBinding = (source: { page: unknown }, text: unknown) => void; let clipboardBinding: ClipboardBinding | null = null; @@ -266,6 +269,7 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ beforeEach(() => { contexts.length = 0; + contextRequests.length = 0; contextGate = null; contextFailure = null; desktopTabs.clear(); @@ -380,18 +384,37 @@ it.live("enforces provider ownership and explicit targets when a session has mul .invoke({ scope: asSession("agent-b"), tabId, - operation: "evaluate", - input: { expression: "foreign()" }, + operation: "navigate", + input: { url: "http://localhost:5173/foreign" }, }) .pipe(Effect.flip); expect(foreign).toMatchObject({ _tag: "PreviewAutomationControlInterruptedError", reason: "agentMismatch", }); - expect(contexts[0]!.sessions[0]!.send).not.toHaveBeenCalledWith( - "Runtime.evaluate", + expect(contexts[0]!.page.goto).not.toHaveBeenCalledWith( + "http://localhost:5173/foreign", expect.anything(), ); + // Another session may still read the tab, but not run page script in it. + yield* broker.invoke({ + scope: asSession("agent-b"), + tabId, + operation: "snapshot", + input: {}, + }); + const foreignEvaluate = yield* broker + .invoke({ + scope: asSession("agent-b"), + tabId, + operation: "evaluate", + input: { expression: "read()" }, + }) + .pipe(Effect.flip); + expect(foreignEvaluate).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "agentMismatch", + }); yield* broker.invoke({ scope, operation: "open", @@ -1323,6 +1346,179 @@ it.live("a desktop page the desktop takes back reconnects instead of closing", ( ).pipe(Effect.provide(layer)), ); +it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ + threadId: scope.thread.threadId, + url: "http://localhost:5173/mine", + runtime: "server", + }); + const tabId = PreviewTabId.make(opened.tabId); + // The user opened and is looking at the tab; attaching takes control. + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const status = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + }); + expect(status).toMatchObject({ + tabId, + control: { owner: "human", ownedByCaller: false }, + }); + expect(status.tabs).toEqual([ + expect.objectContaining({ tabId, owner: "human", ownedByCaller: false, visible: true }), + ]); + // Reads work while the user drives; page script does not, since it can change the page. + const evaluated = yield* broker + .invoke({ scope, operation: "evaluate", input: { expression: "read()" } }) + .pipe(Effect.flip); + expect(evaluated).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "humanControl", + }); + yield* broker.invoke({ + scope, + tabId, + operation: "snapshot", + input: {}, + }); + // Acting is refused while the user controls the tab. + const refused = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/agent" }, + }) + .pipe(Effect.flip); + expect(refused).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "humanControl", + }); + // Once they let go, the tab is unclaimed and the agent may act on it. + yield* viewer.input({ type: "releaseControl" }); + const released = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(released.control).toMatchObject({ owner: "unclaimed", ownedByCaller: false }); + yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/agent" }, + }); + expect(contexts[0]!.page.goto).toHaveBeenCalledWith( + "http://localhost:5173/agent", + expect.anything(), + ); + // Taking control back refuses the agent again. + yield* viewer.input({ type: "takeControl" }); + const retaken = yield* broker + .invoke({ scope, tabId, operation: "press", input: { key: "Enter" } }) + .pipe(Effect.flip); + expect(retaken).toMatchObject({ reason: "humanControl" }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a session's own tab stays its default while other sessions' tabs are listed", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const other = yield* broker.invoke({ + scope: asSession("agent-b"), + operation: "open", + input: { reuseExistingTab: false, show: false }, + }); + const status = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + }); + expect(status.tabId).toBe(tabId); + expect(status.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tabId, owner: "agent", ownedByCaller: true }), + expect.objectContaining({ tabId: other.tabId, owner: "agent", ownedByCaller: false }), + ]), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live( + "preview_open picks a reported profile by id or name and defaults to the reported one", + () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + yield* browser.reportProfiles({ + profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }], + defaultProfileId: "profile-work", + }); + const openWith = (profileId?: string) => + broker.invoke({ + scope, + operation: "open", + input: { + reuseExistingTab: false, + show: false, + ...(profileId === undefined ? {} : { profileId }), + }, + }); + const byDefault = yield* openWith(); + const byName = yield* openWith("WORK"); + const byId = yield* openWith("incognito"); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + const profileOf = (tabId: string | null) => + sessions.find((session) => session.tabId === tabId)?.profileId; + expect(profileOf(byDefault.tabId)).toBe("profile-work"); + expect(profileOf(byName.tabId)).toBe("profile-work"); + expect(profileOf(byId.tabId)).toBe("incognito"); + // The chosen profile reaches the headless tab's storage. + expect(contextRequests).toEqual([ + { profileId: "profile-work", isolated: false }, + { profileId: "profile-work", isolated: false }, + { profileId: "incognito", isolated: true }, + ]); + expect(byDefault).toMatchObject({ + defaultProfileId: "profile-work", + profiles: [ + { id: "default", name: "Default" }, + { id: "incognito", name: "Incognito", incognito: true }, + { id: "profile-work", name: "Work" }, + ], + }); + const unknown = yield* openWith("Personal").pipe(Effect.flip); + expect(unknown).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + reason: + 'No browser profile is named "Personal". Use one of: Default (id default), Incognito (id incognito), Work (id profile-work).', + }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("an agent tab keeps throwaway storage when no client reported profiles", () => + Effect.scoped( + Effect.gen(function* () { + yield* ready; + expect(contextRequests).toEqual([{ profileId: "default", isolated: true }]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a desktop page that comes back reconnects without waiting for a viewer", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 7ca2e1ffba8e..f3d9bea8b40f 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2,8 +2,14 @@ // Screencasts ignore emulated device scale; real 2x keeps captures sharp. // --disable-gpu uses cheaper software compositing while preserving SwiftShader WebGL. import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, + findBrowserProfile, INCOGNITO_BROWSER_PROFILE_ID, + resolveBrowserProfiles, + type BrowserProfile, + type PreviewReportProfilesInput, PREVIEW_AUTOMATION_SERVER_OPERATIONS, PreviewViewportSetting as PreviewViewportSettingSchema, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -243,6 +249,11 @@ export class ServerBrowser extends Context.Service< }) => Effect.Effect>; /** Deletes a human profile's server-side storage, closing its open tabs first. */ readonly clearProfile: (profileId: string) => Effect.Effect; + /** + * Records a client's browser profiles for agents' preview_open. In memory: + * clients report again on connect and whenever the list changes. + */ + readonly reportProfiles: (input: PreviewReportProfilesInput) => Effect.Effect; } >()("t3/preview/ServerBrowser") {} @@ -346,6 +357,17 @@ interface ServerDownload { } /** One agent session and the whole server; each tab holds a renderer process. */ +/** + * Operations an agent may run on any tab in its thread, even while a human + * drives it. Evaluate is not one: page script can change anything, so it needs + * the same control as clicking. + */ +const READ_OPERATIONS: ReadonlySet = new Set([ + "status", + "snapshot", + "waitFor", +]); + const AGENT_TAB_LIMIT = 8; const SERVER_TAB_LIMIT = 32; /** Agent tabs nobody watches close after this long without an agent request. */ @@ -727,10 +749,11 @@ const make = Effect.gen(function* () { adopted === undefined && (await desktopRenders(snapshot)) ? await connectDesktop(snapshot) : null; + // An agent tab without a profile (no client reported one) keeps throwaway storage. const isolatedContext = adopted === undefined && desktop === null && - (snapshot.automationOwner !== undefined || + ((snapshot.automationOwner !== undefined && snapshot.profileId === undefined) || snapshot.profileId === INCOGNITO_BROWSER_PROFILE_ID); const context = adopted?.page.context() ?? @@ -1199,10 +1222,75 @@ const make = Effect.gen(function* () { } }; - const latestThreadTab = (threadId: string, agentSessionId?: string) => - [...tabs.values()] - .filter((tab) => tab.threadId === threadId && tab.control.agentId === agentSessionId) - .sort((left, right) => right.createdAt - left.createdAt)[0]; + /** + * The tab a request without a tabId means: the caller's newest tab, else the + * thread's tab the user is looking at or used last, so "this page" works. + */ + const latestThreadTab = (threadId: string, agentSessionId?: string) => { + const threadTabs = [...tabs.values()].filter((tab) => tab.threadId === threadId); + return ( + threadTabs + .filter((tab) => tab.control.agentId === agentSessionId) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? + threadTabs.sort( + (left, right) => + Number(right.viewers.size > 0) - Number(left.viewers.size > 0) || + right.usedAt - left.usedAt, + )[0] + ); + }; + + const tabOwner = (tab: ServerTab) => + tab.control.controller !== null + ? ("human" as const) + : tab.control.agentId !== null + ? ("agent" as const) + : ("unclaimed" as const); + + /** The latest profile list a client reported; agents choose from it. */ + let reportedProfiles: { + readonly profiles: ReadonlyArray; + readonly defaultProfileId: string; + } | null = null; + + const reportProfiles: ServerBrowser["Service"]["reportProfiles"] = (input) => + Effect.sync(() => { + const profiles = resolveBrowserProfiles(input.profiles); + reportedProfiles = { + profiles, + defaultProfileId: + findBrowserProfile(profiles, input.defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID, + }; + }); + + const profileStatus = () => { + const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; + return { + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + ...(profile.kind === "incognito" ? { incognito: true } : {}), + })), + ...(reportedProfiles ? { defaultProfileId: reportedProfiles.defaultProfileId } : {}), + }; + }; + + /** Matches an id, then a case-insensitive name. Omitted means the reported default. */ + const resolveOpenProfile = (requested: string | undefined): string | undefined => { + if (requested === undefined) return reportedProfiles?.defaultProfileId; + const profiles = reportedProfiles?.profiles ?? BUILT_IN_BROWSER_PROFILES; + const wanted = requested.toLowerCase(); + const match = + findBrowserProfile(profiles, requested) ?? + profiles.find((profile) => profile.name.toLowerCase() === wanted); + if (match) return match.id; + throw new ServerBrowserPage.ServerBrowserOperationError( + "PreviewAutomationUnknownProfileError", + `No browser profile is named "${requested}". Use one of: ${profiles + .map((profile) => `${profile.name} (id ${profile.id})`) + .join(", ")}.`, + ); + }; const statusWithTitle = async ( tab: ServerTab | undefined, @@ -1216,6 +1304,7 @@ const make = Effect.gen(function* () { url: null, title: null, loading: false, + ...profileStatus(), }; } const url = tab.page.url(); @@ -1228,12 +1317,7 @@ const make = Effect.gen(function* () { title: null, loading: tab.loading, control: { - owner: - tab.control.controller !== null - ? ("human" as const) - : tab.control.agentId !== null - ? ("agent" as const) - : ("unclaimed" as const), + owner: tabOwner(tab), ownedByCaller: tab.control.agentId === agentSessionId, generation: tab.control.generation, }, @@ -1244,15 +1328,17 @@ const make = Effect.gen(function* () { viewportSetting: tab.setting, ...(viewport ? { viewport } : {}), tabs: [...tabs.values()] - .filter( - (candidate) => - candidate.threadId === tab.threadId && candidate.control.agentId === agentSessionId, - ) + .filter((candidate) => candidate.threadId === tab.threadId) .map((candidate) => ({ tabId: candidate.tabId, url: candidate.page.url() === "about:blank" ? null : candidate.page.url(), ...(candidate.openerTabId === undefined ? {} : { openerTabId: candidate.openerTabId }), + owner: tabOwner(candidate), + ownedByCaller: candidate.control.agentId === agentSessionId, + visible: candidate.viewers.size > 0, + ...(candidate.profileId === undefined ? {} : { profileId: candidate.profileId }), })), + ...profileStatus(), downloads: tab.downloads.map(({ fileName, path, sizeBytes, url, completedAt }) => ({ fileName, path, @@ -1554,19 +1640,26 @@ const make = Effect.gen(function* () { "No server preview tab is open for this thread. Call preview_open first.", ); } - if (tab.control.agentId !== request.agentSessionId) + // Any tab in the thread can be read; acting is checked by its control. + if (!READ_OPERATIONS.has(request.operation) && !tab.control.agentMayAct(request.agentSessionId)) throw new BrowserControlInterrupted( - "This tab belongs to another agent session or a human. Open your own tab.", + "This tab belongs to another agent session. You can read it, but open your own tab to act.", "agentMismatch", ); return tab; }; - /** Any request, even a failed one, keeps the agent's tabs on the thread from idling out. */ + /** + * Any request, even a failed one, keeps the agent's tabs on the thread from + * idling out. The tab it targeted becomes the thread's most recently used. + */ const markUsed = (request: PreviewAutomationRequest) => { const now = Date.now(); for (const tab of tabs.values()) { - if (tab.threadId === request.threadId && tab.control.agentId === request.agentSessionId) + if ( + tab.threadId === request.threadId && + (tab.control.agentId === request.agentSessionId || tab.tabId === request.tabId) + ) tab.usedAt = now; } }; @@ -1602,7 +1695,7 @@ const make = Effect.gen(function* () { "tabRequired", ); // A tab still launching exists only as a session, so resolve it like a viewer would. - const existing = + const found = reuse && request.tabId !== undefined ? await Effect.runPromise( findTab(request.threadId, request.tabId).pipe( @@ -1612,7 +1705,14 @@ const make = Effect.gen(function* () { ), ) : undefined; + // Only an explicit tabId reuses a tab this session did not open; a tab + // it last read (such as the user's) is not taken over implicitly. + const existing = + found && (request.tabIdExplicit || found.control.agentId === request.agentSessionId) + ? found + : undefined; const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS); + const profileId = existing ? undefined : resolveOpenProfile(open.profileId); if (!existing) { closeIdleAgentTabs(); assertTabCapacity(request.agentSessionId); @@ -1624,6 +1724,7 @@ const make = Effect.gen(function* () { manager.open({ threadId: request.threadId, ...(url ? { url } : {}), + ...(profileId === undefined ? {} : { profileId }), runtime: "server", reveal: false, automationOwner: request.agentSessionId, @@ -1681,7 +1782,7 @@ const make = Effect.gen(function* () { const recordings = [...tabs.values()].filter( (candidate) => candidate.threadId === request.threadId && - candidate.control.agentId === request.agentSessionId && + candidate.control.agentMayAct(request.agentSessionId ?? "") && (candidate.recording || candidate.recordingStart), ); const targetTabId = @@ -1701,6 +1802,11 @@ const make = Effect.gen(function* () { const tab = await requireTab(request); // Closing must unblock an action waiting on a dialog, without queueing behind it. if (request.operation === "close") { + if (tab.control.agentId !== request.agentSessionId) + throw new BrowserControlInterrupted( + "Only the agent session that opened this tab can close it.", + "agentMismatch", + ); if (tab.control.controller !== null) throw new BrowserControlInterrupted("A human controls this tab.", "humanControl"); void tab.control.close().catch(constVoid); @@ -1716,7 +1822,26 @@ const make = Effect.gen(function* () { await resolveDialog(tab, input as PreviewAutomationDialogInput); return statusWithTitle(tab, request.agentSessionId); } - return tab.control.agent(request.agentSessionId!, async () => { + const agentSessionId = request.agentSessionId!; + // A tab this session did not open, while it cannot act there (another + // agent's, or the user's while they drive): read it in place, without + // queueing behind the human's input. Its own tabs keep the takeover + // contract: a human taking control interrupts every agent call. + if ( + READ_OPERATIONS.has(request.operation) && + tab.control.agentId !== agentSessionId && + !tab.control.agentCanActNow(agentSessionId) + ) { + return tab.control.observe(async () => { + if (tab.dialog) + throw new BrowserControlInterrupted( + "A browser dialog is pending. Read preview_status.", + "dialogPending", + ); + return executeTabOperation(tab, request); + }); + } + return tab.control.agent(agentSessionId, async () => { if (tab.dialog) throw new BrowserControlInterrupted( "A browser dialog is pending. Read preview_status and use preview_dialog first.", @@ -2328,6 +2453,7 @@ const make = Effect.gen(function* () { return ServerBrowser.of({ attachViewer, clearProfile, + reportProfiles, openDownload, answerFileChooser, }); diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index e3366178b668..0654ea62a2b8 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -87,6 +87,7 @@ it.effect.each([ }); const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, + reportProfiles: () => Effect.void, openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: (input) => @@ -163,6 +164,7 @@ it.effect.each([ let attachments = 0; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, + reportProfiles: () => Effect.void, openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => { @@ -203,6 +205,7 @@ it.effect("serves a tab's download only to an authorized session", () => const requests: Array = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, + reportProfiles: () => Effect.void, openDownload: (input) => Effect.sync(() => { requests.push(input); @@ -250,6 +253,7 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco const answers: Array<{ chooserId: string; files: Array<{ name: string; text: string }> }> = []; const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, + reportProfiles: () => Effect.void, openDownload: () => Effect.succeedNone, answerFileChooser: (input) => Effect.sync(() => { @@ -317,6 +321,7 @@ it.effect.each([ Effect.gen(function* () { const browser = ServerBrowser.ServerBrowser.of({ clearProfile: () => Effect.void, + reportProfiles: () => Effect.void, openDownload: () => Effect.succeedNone, answerFileChooser: () => Effect.succeed(false), attachViewer: () => Effect.fail(new ServerBrowser.ServerBrowserLaunchError({ cause: error })), diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index 73d64cee1019..42eabc212ac0 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -166,4 +166,16 @@ describe("SessionControl", () => { ).rejects.toThrow("navigation failed"); await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered"); }); + + it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => { + const control = new SessionControl(null); + await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted"); + await control.take("viewer-a"); + await expect(control.agent("agent-a", async () => "racing")).rejects.toMatchObject({ + reason: "humanControl", + }); + await expect(control.observe(async () => "read")).resolves.toBe("read"); + await control.release("viewer-a"); + await expect(control.agent("agent-b", async () => "acted")).resolves.toBe("acted"); + }); }); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index a361cd1a467c..a930a5e115fc 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -73,8 +73,17 @@ export class SessionControl { }); } + /** Whether an agent may act here: its own tab, or a tab no agent opened. */ + agentMayAct(agentId: string) { + return this.agentId === null || this.agentId === agentId; + } + + /** + * An agent action. It may act on its own tab or on one a human opened, but + * never while a human controls the tab; taking control interrupts it. + */ agent
(agentId: string, run: () => Promise) { - if (this.agentId !== agentId) + if (!this.agentMayAct(agentId)) return Promise.reject( new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"), ); @@ -82,7 +91,21 @@ export class SessionControl { return Promise.reject( new BrowserControlInterrupted("A human controls this tab.", "humanControl"), ); - return this.action(() => this.agentId === agentId && this.owner === null, run); + return this.action(() => this.agentMayAct(agentId) && this.owner === null, run); + } + + /** Whether this agent's action would run now rather than be refused. */ + agentCanActNow(agentId: string) { + return this.agentMayAct(agentId) && this.owner === null; + } + + /** + * A read that needs no control and does not queue, so an agent can look at + * a page while a human drives it without holding up the human's input. + */ + observe(run: () => Promise) { + this.assertOpen(); + return run(); } /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5e2dfc6b5dc7..eee2755fcf08 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2899,6 +2899,7 @@ const layerWsRpc = ( [WS_METHODS.previewClose]: (input) => previewManager.close(input), [WS_METHODS.previewList]: (input) => previewManager.list(input), [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), + [WS_METHODS.previewReportProfiles]: (input) => serverBrowser.reportProfiles(input), [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input), [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index 443c260f5e86..1ec12cfdd712 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,5 +1,6 @@ import { RouterProvider } from "@tanstack/react-router"; +import { BrowserProfileReporter } from "./browser/BrowserProfileReporter"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; @@ -15,6 +16,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); diff --git a/apps/web/src/browser/BrowserProfileReporter.tsx b/apps/web/src/browser/BrowserProfileReporter.tsx new file mode 100644 index 000000000000..e148f79d72af --- /dev/null +++ b/apps/web/src/browser/BrowserProfileReporter.tsx @@ -0,0 +1,40 @@ +import { AuthPreviewOperateScope, type BrowserProfile } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { useClientSettings, useClientSettingsHydrated } from "~/hooks/useSettings"; +import { useServerConfigs } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; +import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentsWithScope } from "~/state/session"; +import { useAtomCommand } from "~/state/use-atom-command"; + +const selectProfiles = (settings: { readonly browserProfiles: ReadonlyArray }) => + settings.browserProfiles; +const selectDefaultProfileId = (settings: { readonly browserDefaultProfileId: string }) => + settings.browserDefaultProfileId; + +/** + * Tells each environment that hosts browser tabs which profiles this client + * has, so agents can open tabs under them. Profiles live in client settings; + * the server keeps only the latest report in memory. A reconnect brings a new + * server config, which sends the report again. + */ +export function BrowserProfileReporter() { + const hydrated = useClientSettingsHydrated(); + const profiles = useClientSettings(selectProfiles); + const defaultProfileId = useClientSettings(selectDefaultProfileId); + const { environments } = useEnvironments(); + const serverConfigs = useServerConfigs(); + const operable = useEnvironmentsWithScope(environments, AuthPreviewOperateScope); + const report = useAtomCommand(previewEnvironment.reportProfiles, { reportFailure: false }); + + useEffect(() => { + if (!hydrated) return; + for (const [environmentId, config] of serverConfigs) { + if (!config.environment.capabilities.serverBrowser || !operable.has(environmentId)) continue; + void report({ environmentId, input: { profiles, defaultProfileId } }); + } + }, [defaultProfileId, hydrated, operable, profiles, report, serverConfigs]); + + return null; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index e32dcb9ee4df..b076a30a5221 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -3,11 +3,12 @@ import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { type ComponentProps, useEffect, useMemo, useRef } from "react"; +import { type ComponentProps, useEffect, useMemo, useRef, useState } from "react"; import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { isElectron } from "~/env"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { useActivePreviewSessions } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; @@ -15,6 +16,7 @@ import { useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; +import { useBrowserDefaults } from "./browserDefaults"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; @@ -158,5 +160,23 @@ function AuthorizedBrowserWebview(props: ComponentProps : null; + const profileId = useTabProfileId(props.profileId); + return canOperatePreview ? : null; +} + +/** + * Agent `preview_open` normally carries the profile clients reported (see + * BrowserProfileReporter). An agent tab opened before any client reported has + * none, so it falls back to the configured default here. It is latched once + * settings load: Electron fixes the partition when the guest attaches, so a + * later settings change must not move a live tab. + */ +function useTabProfileId(profileId: string | undefined): string | undefined { + const hydrated = useClientSettingsHydrated(); + const defaultProfileId = useBrowserDefaults().profileId; + const [fallback, setFallback] = useState(undefined); + if (profileId === undefined && hydrated && fallback === undefined) { + setFallback(defaultProfileId); + } + return profileId ?? fallback; } diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index 69ed75b9ea65..72d47b742699 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -76,6 +76,11 @@ export function createPreviewEnvironmentAtoms( label: "environment-data:preview:clear-profile", tag: WS_METHODS.previewClearProfile, }), + reportProfiles: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:report-profiles", + tag: WS_METHODS.previewReportProfiles, + concurrency: { mode: "latest", key: ({ environmentId }) => environmentId }, + }), reportStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:report-status", tag: WS_METHODS.previewReportStatus, diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index e534fff06984..802bbd4765b7 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -10,7 +10,7 @@ */ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { BrowserProfileId } from "./browserProfile.ts"; +import { BROWSER_PROFILE_MAX_COUNT, BrowserProfile, BrowserProfileId } from "./browserProfile.ts"; export const PREVIEW_URL_MAX_LENGTH = 2_048; export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; @@ -295,6 +295,14 @@ export const PreviewCloseInput = Schema.Struct({ }); export type PreviewCloseInput = typeof PreviewCloseInput.Type; +/** A client's browser profiles, which agents choose from when they open a tab. */ +export const PreviewReportProfilesInput = Schema.Struct({ + /** The user's own profiles; the server adds the built-ins. */ + profiles: Schema.Array(BrowserProfile).check(Schema.isMaxLength(BROWSER_PROFILE_MAX_COUNT)), + defaultProfileId: BrowserProfileId, +}); +export type PreviewReportProfilesInput = typeof PreviewReportProfilesInput.Type; + export const PreviewClearProfileInput = Schema.Struct({ profileId: BrowserProfileId, }); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index fa16511bd3e8..6634e73b6990 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -10,12 +10,15 @@ import { PreviewViewportSize, } from "./preview.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; const BoundedUrl = Schema.String.check(Schema.isTrimmed()) .check(Schema.isNonEmpty()) .check(Schema.isMaxLength(2048)); const URL_GUIDANCE = "Absolute http(s) URL or a schemeless host such as t3.chat or localhost:5173. Schemeless public hosts use https; loopback hosts use http."; +const PROFILE_GUIDANCE = + "Browser profile for a new tab, by id or name (case-insensitive) from preview_status profiles. Omit to use the user's default profile. A tab keeps the profile it opened with."; const OptionalTimeoutMs = Schema.optional( Schema.Int.check(Schema.isGreaterThan(0)) .check(Schema.isLessThanOrEqualTo(60_000)) @@ -61,17 +64,27 @@ const PreviewAutomationTabTargetFields = { tabId: Schema.optional( PreviewTabId.annotate({ description: - "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", + "Exact collaborative browser tab to target, including a tab the user opened (see preview_status tabs). Omit to use this agent session's current tab, or the user's visible tab when this session has none.", }), ).annotate({ description: - "Exact collaborative browser tab to target. Omit to use this agent session's current tab.", + "Exact collaborative browser tab to target, including a tab the user opened (see preview_status tabs). Omit to use this agent session's current tab, or the user's visible tab when this session has none.", }), }; export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; +/** `human`: a person controls the tab now; `agent`: an agent session opened it; `unclaimed`: neither. */ +export const PreviewAutomationTabOwner = Schema.Literals(["agent", "human", "unclaimed"]); + +export const PreviewAutomationProfile = Schema.Struct({ + id: BrowserProfileId, + name: Schema.String, + incognito: Schema.optional(Schema.Boolean), +}); +export type PreviewAutomationProfile = typeof PreviewAutomationProfile.Type; + export const PreviewAutomationStatus = Schema.Struct({ available: Schema.Boolean, visible: Schema.Boolean, @@ -81,7 +94,7 @@ export const PreviewAutomationStatus = Schema.Struct({ loading: Schema.Boolean, control: Schema.optional( Schema.Struct({ - owner: Schema.Literals(["agent", "human", "unclaimed"]), + owner: PreviewAutomationTabOwner, ownedByCaller: Schema.Boolean, generation: Schema.Number, }), @@ -115,16 +128,28 @@ export const PreviewAutomationStatus = Schema.Struct({ }), ), ), - /** Server hosts: every tab this agent session owns, including popups its pages opened. */ + /** + * Server hosts: every tab in the thread, including ones the user or another + * agent session opened. Any of them can be read; acting needs `ownedByCaller`, + * or an `unclaimed` tab while no human controls it. + */ tabs: Schema.optional( Schema.Array( Schema.Struct({ tabId: PreviewTabId, url: Schema.NullOr(Schema.String), openerTabId: Schema.optional(PreviewTabId), + owner: Schema.optional(PreviewAutomationTabOwner), + ownedByCaller: Schema.optional(Schema.Boolean), + visible: Schema.optional(Schema.Boolean), + profileId: Schema.optional(BrowserProfileId), }), ), ), + /** Server hosts: the browser profiles preview_open accepts, as the user's client reported them. */ + profiles: Schema.optional(Schema.Array(PreviewAutomationProfile)), + /** Server hosts: the profile preview_open uses when given none. */ + defaultProfileId: Schema.optional(BrowserProfileId), }); export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; @@ -162,6 +187,11 @@ export const PreviewAutomationOpenInput = Schema.Struct({ "Reuse tabId when supplied, otherwise this agent session's current tab. Defaults to true; set false to create a new tab.", }), ), + profileId: Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(64)).annotate({ + description: PROFILE_GUIDANCE, + }), + ).annotate({ description: PROFILE_GUIDANCE }), }) .check( Schema.makeFilter( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 875223bfd1c0..ed14e2e16b7c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -256,6 +256,7 @@ import { PreviewListResult, PreviewClearProfileError, PreviewClearProfileInput, + PreviewReportProfilesInput, PreviewNavigateInput, PreviewOpenInput, PreviewRefreshInput, @@ -444,6 +445,7 @@ export const WS_METHODS = { previewClose: "preview.close", previewList: "preview.list", previewClearProfile: "preview.clearProfile", + previewReportProfiles: "preview.reportProfiles", previewReportStatus: "preview.reportStatus", // Device methods @@ -1475,6 +1477,11 @@ const WsPreviewClearProfileRpc = Rpc.make(WS_METHODS.previewClearProfile, { error: Schema.Union([PreviewClearProfileError, EnvironmentAuthorizationError]), }); +const WsPreviewReportProfilesRpc = Rpc.make(WS_METHODS.previewReportProfiles, { + payload: PreviewReportProfilesInput, + error: EnvironmentAuthorizationError, +}); + const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), @@ -1955,6 +1962,7 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewCloseRpc, WsPreviewListRpc, WsPreviewClearProfileRpc, + WsPreviewReportProfilesRpc, WsPreviewReportStatusRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 2280d4d25619..6dcf3875ff25 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -44,7 +44,12 @@ You are running inside T3 Code. The \`t3-code\` MCP server is the product-native For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. +\`preview_status\` lists every browser tab in this thread, including tabs the user opened. When the user asks about "this page" or a page they have open, read their tab: pass its \`tabId\` to \`preview_snapshot\` or \`preview_wait_for\`, or omit \`tabId\` when you have no tab of your own. You may act on the user's tab, including \`preview_evaluate\`, only while its owner is \`unclaimed\`; while it is \`human\`, the user is driving, so read it with \`preview_snapshot\` or open your own tab. To use a browser profile (a set of saved logins), pass \`profileId\` from \`preview_status\` profiles to \`preview_open\`. + +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Inspect a failed preview call and retry with corrected arguments when the error is actionable. Use another browser system when: +- the T3 preview tools are absent, or \`preview_open\` returns an explicit unsupported/unavailable error; +- the user asks for another browser, or invokes a skill or documented repository workflow that names one; follow that workflow and report any prerequisite it is missing; +- preview calls on an open tab have failed twice on the same step (timeouts, \`chrome-error://\` pages, a different client answering). Quote the raw error and switch without asking the user which browser to use. `; const T3_CODE_ACP_DEFAULT_MODE_INSTRUCTIONS = `## T3 Code interaction mode: Default From 29980a31409234b676f97bd477c4e46fdb61a929 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 19:22:52 -0500 Subject: [PATCH 041/377] fix(web): add room for thread timeline markers (#17372) --- apps/web/src/components/chat/ChatCanvas.tsx | 4 +- .../components/chat/chatCanvasLayout.test.ts | 52 +++++++++++++------ .../src/components/chat/chatCanvasLayout.ts | 2 +- .../chat/threadDetailsCardLayout.test.ts | 6 +-- apps/web/src/index.css | 10 ++-- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 3606f6db9700..4df7ad66deb7 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -41,7 +41,7 @@ export function ChatCanvas({ const [measurements, setMeasurements] = useState({ width: 0, height: 0, - padding: 20, + padding: 48, maxChatWidth: 768, minChatWidth: 640, composerHeight: 0, @@ -139,7 +139,7 @@ export function ChatCanvas({
{children}
diff --git a/apps/web/src/components/chat/chatCanvasLayout.test.ts b/apps/web/src/components/chat/chatCanvasLayout.test.ts index ea99128a73d0..2d6f77af1b48 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.test.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.test.ts @@ -51,7 +51,13 @@ describe("chat canvas layout", () => { it("centers chat in the whole container without a preview", () => { expect(resolve(1344, null).chat).toEqual({ left: 288, width: 768, insetStart: 0, insetEnd: 0 }); - expect(resolve(390, null).chat).toEqual({ left: 20, width: 350, insetStart: 0, insetEnd: 0 }); + expect( + resolveChatCanvasLayout({ + container: { width: 390, height: 900 }, + preview: null, + padding: 12, + }).chat, + ).toEqual({ left: 12, width: 366, insetStart: 0, insetEnd: 0 }); }); it("does not move chat when a bottom-right preview fits in its margin", () => { const result = resolve(1600); @@ -66,21 +72,21 @@ describe("chat canvas layout", () => { }); it("shrinks chat modestly after using the left margin", () => { const result = resolve(1200, { ...preview, width: 480 }); - expect(result.chat.width).toBe(676); - expect(result.chat.left).toBe(20); + expect(result.chat.width).toBe(648); + expect(result.chat.left).toBe(48); expectClear(result); }); it("stops leftward dragging before the player can push chat to the right", () => { const centered = resolve(1344, null).chat; - for (const x of [1100, 800, 672, 600, 400, 12, -100]) { + for (const x of [1100, 800, 700, 600, 400, 12, -100]) { const result = resolve(1344, { ...preview, position: { x, y: 500 } }); - expect(result.frame!.x).toBeGreaterThanOrEqual(672); + expect(result.frame!.x).toBeGreaterThanOrEqual(700); expect(result.chat.left).toBeLessThanOrEqual(centered.left); expect(result.chat.insetStart).toBe(0); expect(result.chat.width).toBeGreaterThanOrEqual(640); expectClear(result); } - expect(resolve(1344, { ...preview, position: { x: 12, y: 500 } }).frame!.x).toBe(672); + expect(resolve(1344, { ...preview, position: { x: 12, y: 500 } }).frame!.x).toBe(700); }); it("keeps a dragged player above the composer when no readable lane fits beside it", () => { const result = resolve(1000, { ...preview, position: { x: 12, y: 700 } }); @@ -171,8 +177,8 @@ describe("chat canvas layout", () => { expect(shifted.overlapsDetailsCard).toBe(false); expectClear(shifted); - const narrowed = layout(560); - expect(narrowed.frame!.width).toBe(560); + const narrowed = layout(532); + expect(narrowed.frame!.width).toBe(532); expect(narrowed.chat.width).toBe(656); expect(narrowed.overlapsDetailsCard).toBe(false); expectClear(narrowed); @@ -182,7 +188,7 @@ describe("chat canvas layout", () => { expect(grown.overlapsDetailsCard).toBe(true); expect(grown.overlapsChat).toBe(false); expectClear(grown); - expect(layout(560)).toEqual(narrowed); + expect(layout(532)).toEqual(narrowed); }); it.each(["west", "north"] as const)( "folds the card before moving a %s resize beside it", @@ -214,7 +220,7 @@ describe("chat canvas layout", () => { const density = (result: ReturnType) => { const card = resolveThreadDetailsCardLayout({ container, - lane: { padding: 20, minChatWidth: 640 }, + lane: { padding: 48, minChatWidth: 640 }, frame: result.frame, overlapsDetailsCard: result.overlapsDetailsCard, }); @@ -274,16 +280,30 @@ describe("workspace card beside chat", () => { expect(withCard(1147).chat).toEqual({ left: 87, width: 736, insetStart: 0, insetEnd: 237 }); }); it("narrows chat only after it reaches the left padding", () => { - expect(withCard(1080).chat).toMatchObject({ left: 20, width: 736 }); - expect(withCard(1000).chat).toEqual({ left: 20, width: 656, insetStart: 0, insetEnd: 304 }); - expect(withCard(984).chat).toMatchObject({ left: 20, width: 640 }); + expect(withCard(1108).chat).toMatchObject({ left: 48, width: 736 }); + expect(withCard(1028).chat).toEqual({ left: 48, width: 656, insetStart: 0, insetEnd: 276 }); + expect(withCard(1012).chat).toMatchObject({ left: 48, width: 640 }); + }); + it("reserves the marker gutter across widths where the details card fits", () => { + for (let width = 1012; width <= 1440; width++) { + const container = { width, height: 900 }; + const card = resolveThreadDetailsCardLayout({ + container, + lane: { padding: 48, minChatWidth: 640 }, + frame: null, + })!; + const chat = withCard(width).chat; + expect(chat.left).toBeGreaterThanOrEqual(48); + expect(chat.width).toBeGreaterThanOrEqual(640); + expect(card.x - chat.left - chat.width).toBeGreaterThanOrEqual(32); + } }); it("keeps a full-width chat clear of the card", () => { expect(withCard(1147, null, 10_000).chat).toEqual({ - left: 20, - width: 803, + left: 48, + width: 775, insetStart: 0, - insetEnd: 304, + insetEnd: 276, }); }); it("keeps centered chat in place when a new preview opens below the card", () => { diff --git a/apps/web/src/components/chat/chatCanvasLayout.ts b/apps/web/src/components/chat/chatCanvasLayout.ts index 714b9e286402..01c5d527ccb7 100644 --- a/apps/web/src/components/chat/chatCanvasLayout.ts +++ b/apps/web/src/components/chat/chatCanvasLayout.ts @@ -27,7 +27,7 @@ export const DETAILS_CARD_CLEARANCE = 32; export function resolveChatCanvasLayout({ container, preview, - padding = 20, + padding = 48, maxChatWidth = 768, minChatWidth = 640, composerHeight = 0, diff --git a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts index e7d961bc811c..923dbc4e521c 100644 --- a/apps/web/src/components/chat/threadDetailsCardLayout.test.ts +++ b/apps/web/src/components/chat/threadDetailsCardLayout.test.ts @@ -4,7 +4,7 @@ import { resolveThreadDetailsCardLayout, } from "./threadDetailsCardLayout"; -const lane = { padding: 20, minChatWidth: 640 }; +const lane = { padding: 48, minChatWidth: 640 }; const resolve = (width: number, height: number, previewY: number | null = null) => resolveThreadDetailsCardLayout({ container: { width, height }, @@ -33,8 +33,8 @@ describe("workspace card", () => { ).toEqual({ x: 1308, y: 60, width: 280, height: 828 }); }); it("hides when a readable chat lane cannot fit beside it", () => { - expect(resolve(984, 900)).toMatchObject({ x: 692 }); - expect(resolve(983, 900)).toBeNull(); + expect(resolve(1012, 900)).toMatchObject({ x: 720 }); + expect(resolve(1011, 900)).toBeNull(); }); it("keeps the card at the top right while the preview is freely dragged vertically", () => { for (const y of [12, 170, 250, 400, 648]) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 73e56caaf4e6..6ecd5608e39d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2344,18 +2344,16 @@ code { @media (min-width: 640px) { .chat-composer-lane { padding-inline-start: calc( - env(safe-area-inset-left) + 1.25rem + var(--chat-lane-inset-start, 0px) - ); - padding-inline-end: calc( - env(safe-area-inset-right) + 1.25rem + var(--chat-lane-inset-end, 0px) + env(safe-area-inset-left) + 3rem + var(--chat-lane-inset-start, 0px) ); + padding-inline-end: calc(env(safe-area-inset-right) + 3rem + var(--chat-lane-inset-end, 0px)); } [data-chat-canvas] .messages-timeline-scroll { padding-inline-start: calc( - 1.25rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px) + 3rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px) ); padding-inline-end: calc( - 1.25rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) + 3rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) ); } } From b0ee7dd48f868c5c02884345f723bebaf616b13d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 19:52:56 -0500 Subject: [PATCH 042/377] fix(web): drop sidebar context before cancelling pointer drag (#17373) --- .../src/components/Sidebar.pointer.test.ts | 42 +++++++++++++++++++ apps/web/src/components/Sidebar.pointer.ts | 9 ++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 0c171313612e..2a142a32e2ca 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -147,6 +147,48 @@ describe("sidebar pointer lifecycle", () => { expect(inside.onCancel).not.toHaveBeenCalled(); }); + it("drops context when a zero-button move precedes pointerup", () => { + const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); + const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); + document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); + + // Native desktop releases can report the released button on the final move. + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 94, clientY: 32 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 94, clientY: 32 })); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 100, clientY: 40 })); + + expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 94, y: 32 }); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onEnd).not.toHaveBeenCalled(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("cancels a missed sidebar release without committing a reorder", () => { + const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); + const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); + document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 30, clientY: 40 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 30, clientY: 40 })); + + expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 40 }); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onEnd).not.toHaveBeenCalled(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("does not drop context when the button is released before activation", () => { + const onDrop = vi.fn(() => true); + const drag = gesture({ onDrop }); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 90, clientY: 30 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); + + expect(onDrop).not.toHaveBeenCalled(); + expect(drag.onStart).not.toHaveBeenCalled(); + expect(drag.onAbort).toHaveBeenCalledOnce(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(false); + }); + it("resumes reordering when a context drag returns to the sidebar", () => { const drag = gesture({ onMove: (point) => point.x > 50, diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 23f95481485d..ef5fab2edd7c 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -68,10 +68,13 @@ export class SidebarPointerSensor { private move = (event: PointerEvent) => { if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return; - // A release outside the window can be missed. Never activate or continue - // a drag when the initiating button is no longer held. - if ((event.buttons & 1) === 0) return this.cancel(); const coordinates = { x: event.clientX, y: event.clientY }; + // Desktop can report buttons=0 on the final move before pointerup. Let an + // active context drag drop there before cancelling the sidebar sort. + if ((event.buttons & 1) === 0) { + if (this.phase === "dragging") this.props.options.onDrop?.(coordinates); + return this.cancel(); + } if (this.phase === "pending") { const offset = { x: event.clientX - this.pointer.clientX, From 2c7a446e657e705c257db28540819f4c0115b2fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:05:10 -0700 Subject: [PATCH 043/377] refactor(providers): namespace-import service modules in core, Muse, Pi, and testing (#17375) Co-authored-by: Claude Opus 5.5 --- .../provider-core/src/server/adapterDriver.ts | 8 +- packages/provider-core/src/server/driver.ts | 4 +- .../provider-core/src/server/failure.test.ts | 4 +- packages/provider-core/src/server/failure.ts | 6 +- .../src/server/managedProvider.test.ts | 10 +- .../src/server/managedProvider.ts | 10 +- .../src/server/snapshotSettings.ts | 4 +- .../provider-muse/src/server/adapter.test.ts | 37 ++++---- packages/provider-muse/src/server/adapter.ts | 91 ++++++++----------- packages/provider-muse/src/server/driver.ts | 6 +- .../provider-pi/src/server/adapter.test.ts | 23 ++--- packages/provider-pi/src/server/adapter.ts | 10 +- packages/provider-pi/src/server/driver.ts | 6 +- packages/provider-testing/src/host.ts | 10 +- 14 files changed, 112 insertions(+), 117 deletions(-) diff --git a/packages/provider-core/src/server/adapterDriver.ts b/packages/provider-core/src/server/adapterDriver.ts index 6a2e57daab0b..7445dc7ed016 100644 --- a/packages/provider-core/src/server/adapterDriver.ts +++ b/packages/provider-core/src/server/adapterDriver.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import type * as Effect from "effect/Effect"; import type * as Scope from "effect/Scope"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; export class ProviderAdapterDriverCreateError extends Schema.TaggedError()( "ProviderAdapterDriverCreateError", @@ -38,7 +38,11 @@ export interface ProviderAdapterDriver { readonly defaultConfig: () => Config; readonly create: ( input: ProviderAdapterDriverCreateInput, - ) => Effect.Effect; + ) => Effect.Effect< + ProviderAdapter.ProviderAdapterV2Shape, + ProviderAdapterDriverCreateError, + R | Scope.Scope + >; } export type AnyProviderAdapterDriver = ProviderAdapterDriver; diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 88e46b937d13..b177ba926ba4 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -37,7 +37,7 @@ import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type * as ProviderAdapter from "./ProviderAdapter.ts"; import type { ProviderAuthController } from "./auth.ts"; import type { ProviderDriverError } from "./errors.ts"; import type { ServerProviderShape } from "./snapshot.ts"; @@ -101,7 +101,7 @@ export interface ProviderInstance { ProviderConsumeResetCreditOutcome, ProviderDriverError >; - readonly orchestrationAdapter: ProviderAdapterV2Shape; + readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2Shape; readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { diff --git a/packages/provider-core/src/server/failure.test.ts b/packages/provider-core/src/server/failure.test.ts index 47fe4c724eb1..a4429213db28 100644 --- a/packages/provider-core/src/server/failure.test.ts +++ b/packages/provider-core/src/server/failure.test.ts @@ -19,7 +19,7 @@ import { ContextHandoffBudgetError, } from "./failure.ts"; import * as IdAllocator from "./IdAllocator.ts"; -import { ProviderAdapterTurnStartError } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; it("redacts credentials and URL secrets from provider failures", () => { const failure = makeProviderFailure({ @@ -88,7 +88,7 @@ it("does not expose arbitrary cause messages and preserves explicit messages", ( }); it("preserves actionable handoff errors wrapped by turn startup", () => { - const cause = new ProviderAdapterTurnStartError({ + const cause = new ProviderAdapter.ProviderAdapterTurnStartError({ driver: ProviderDriverKind.make("codex"), threadId: ThreadId.make("thread:handoff-error"), providerThreadId: ProviderThreadId.make("provider-thread:handoff-error"), diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 53e663a4c8d9..8a745f83aaac 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -13,7 +13,7 @@ import type { import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; -import type { IdAllocatorV2Shape } from "./IdAllocator.ts"; +import type * as IdAllocator from "./IdAllocator.ts"; import * as Schema from "effect/Schema"; /** Raised when portable history does not fit the target provider's context window. */ @@ -172,7 +172,7 @@ export function makeProviderFailure(input: { } export function makeProviderFailureTurnItem(input: { - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly driver: ProviderDriverKind; readonly threadId: ThreadId; readonly runId: RunId | null; @@ -210,7 +210,7 @@ export function makeProviderFailureTurnItem(input: { } export function makeProviderRetryTurnItem(input: { - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly driver: ProviderDriverKind; readonly threadId: ThreadId; readonly runId: RunId | null; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index f46eb897bbb6..a4d7dc752f1c 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -16,7 +16,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { ProviderHost, type ProviderHostShape } from "./ProviderHost.ts"; +import * as ProviderHost from "./ProviderHost.ts"; import { makeManagedServerProvider } from "./managedProvider.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); @@ -99,11 +99,11 @@ const refreshedSnapshotSecond: ServerProvider = { /** A host whose settings never change and whose background demand is fixed. */ function layerProviderHost(input: { readonly runBackgroundWork: boolean; - readonly settings?: Pick["settings"]; + readonly settings?: Pick["settings"]; }) { return Layer.succeed( - ProviderHost, - ProviderHost.of({ + ProviderHost.ProviderHost, + ProviderHost.ProviderHost.of({ paths: { cwd: process.cwd(), baseDir: "/t3", @@ -281,7 +281,7 @@ describe("makeManagedServerProvider", () => { }; const serverSettingsRef = yield* Ref.make(initialServerSettings); const serverSettingsChanges = yield* PubSub.unbounded(); - const hostSettings: ProviderHostShape["settings"] = { + const hostSettings: ProviderHost.ProviderHostShape["settings"] = { get: Ref.get(serverSettingsRef), changes: Stream.empty, subscribe: PubSub.subscribe(serverSettingsChanges).pipe( diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index a04873f0e608..946f9ad86217 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -15,7 +15,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { ProviderHost } from "./ProviderHost.ts"; +import * as ProviderHost from "./ProviderHost.ts"; import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; import type { ServerProviderShape } from "./snapshot.ts"; @@ -53,8 +53,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly refreshInterval?: Duration.Input; readonly refreshOnInterval?: boolean; readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; -}): Effect.fn.Return { - const host = yield* ProviderHost; +}): Effect.fn.Return< + ServerProviderShape, + ServerSettingsError, + Scope.Scope | ProviderHost.ProviderHost +> { + const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), diff --git a/packages/provider-core/src/server/snapshotSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts index 87286728fe60..5a1d207b15ee 100644 --- a/packages/provider-core/src/server/snapshotSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Stream from "effect/Stream"; -import type { ProviderHostShape } from "./ProviderHost.ts"; +import type * as ProviderHost from "./ProviderHost.ts"; export interface ProviderSnapshotSettings { readonly provider: Settings; @@ -29,7 +29,7 @@ export function haveProviderSnapshotSettingsChanged( export function makeProviderSnapshotSettingsSource( provider: Settings, - settings: ProviderHostShape["settings"], + settings: ProviderHost.ProviderHostShape["settings"], ): { readonly getSettings: Effect.Effect, ServerSettingsError>; readonly streamSettings: Stream.Stream>; diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 59f8adec4026..c73dbb4e2297 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -31,17 +31,13 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import type { MuseItem } from "./protocol.ts"; import type { MuseSdkHost } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; @@ -55,7 +51,7 @@ const INSTANCE_ID = ProviderInstanceId.make("muse_work"); const THREAD_ID = ThreadId.make("thread-muse-test"); const MODEL = "muse-spark-1.3-contributor"; const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true }); -const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ +const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: null, @@ -204,7 +200,7 @@ const makeHarness = Effect.fnUntraced(function* ( settings: museSettings, environment: { PATH: "/fake/bin" }, idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost, + host: yield* ProviderHost.ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), ...overrides, @@ -216,8 +212,8 @@ const makeHarness = Effect.fnUntraced(function* ( runtimePolicy: policy, ...(initialNativeThreadId ? { initialNativeThreadId } : {}), }); - const emitted = yield* Queue.unbounded(); - const allEvents: ProviderAdapterV2Event[] = []; + const emitted = yield* Queue.unbounded(); + const allEvents: ProviderAdapter.ProviderAdapterV2Event[] = []; const eventsEnded = yield* Deferred.make>>(); yield* runtime.events.pipe( Stream.runForEach((event) => @@ -236,14 +232,21 @@ const makeHarness = Effect.fnUntraced(function* ( runtimePolicy: policy, ...(existingProviderThread ? { existingProviderThread } : {}), }); - const takeEvent = Effect.fnUntraced(function* ( + const takeEvent = Effect.fnUntraced(function* < + T extends ProviderAdapter.ProviderAdapterV2Event["type"], + >( type: T, - predicate: (event: Extract) => boolean = () => true, + predicate: ( + event: Extract, + ) => boolean = () => true, ) { while (true) { const event = yield* Queue.take(emitted); - if (event.type === type && predicate(event as Extract)) { - return event as Extract; + if ( + event.type === type && + predicate(event as Extract) + ) { + return event as Extract; } } }); @@ -275,7 +278,7 @@ const turnInput = Effect.fnUntraced(function* ( providerThread: OrchestrationV2ProviderThread, runOrdinal = 1, policy = runtimePolicy, -): Effect.fn.Return { +): Effect.fn.Return { const now = yield* DateTime.now; const threadId = providerThread.appThreadId ?? THREAD_ID; const selection = modelSelection(providerThread.providerInstanceId); @@ -436,7 +439,7 @@ describe("MuseAdapterV2", () => { settings: museSettings, environment: { PATH: "/fake/bin" }, idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost, + host: yield* ProviderHost.ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: async () => fake.host, }); @@ -833,7 +836,7 @@ describe("MuseAdapterV2", () => { it.effect("keeps a workflow child's approval pending for the user past the turn's end", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); - const policy = ProviderAdapterV2RuntimePolicy.make({ + const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ ...runtimePolicy, runtimeMode: "approval-required", }); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index bf1699055534..686fb64f1dc8 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -35,7 +35,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; @@ -63,35 +63,13 @@ import { type createMuseSdkHost, type MuseSdkHost, } from "./sdk.ts"; -import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { providerMessageTextWithAttachmentPaths, isProviderNativeImageAttachment, } from "@t3tools/provider-core/server/attachmentPrompt"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterEnsureThreadError, - ProviderAdapterForkThreadError, - ProviderAdapterInterruptError, - ProviderAdapterOpenSessionError, - ProviderAdapterProtocolError, - ProviderAdapterReadThreadSnapshotError, - ProviderAdapterResumeThreadError, - ProviderAdapterRollbackThreadError, - ProviderAdapterRuntimeRequestResponseError, - ProviderAdapterSteerRunError, - ProviderAdapterTurnStartError, - ProviderAdapterV2, - type ProviderAdapterV2Error, - type ProviderAdapterV2Event, - type ProviderAdapterV2EnsureThreadInput, - type ProviderAdapterV2OpenSessionInput, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2Shape, - type ProviderAdapterV2ThreadSnapshot, - type ProviderAdapterV2TurnInput, - type ProviderAdapterV2TurnMessage, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { backgroundWorkNotification, type BackgroundWorkReport, @@ -102,8 +80,8 @@ import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/sel import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; const MUSE_PROVIDER = ProviderDriverKind.make("muse"); -const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); -const isProtocolError = Schema.is(ProviderAdapterProtocolError); +const isOpenSessionError = Schema.is(ProviderAdapter.ProviderAdapterOpenSessionError); +const isProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError); const MuseProviderCapabilitiesV2 = { runtimePolicy: { enforcement: "native" }, @@ -204,12 +182,12 @@ export interface MuseAdapterV2Options { readonly settings: MuseSettings; readonly environment: NodeJS.ProcessEnv; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly host: ProviderHostShape; + readonly host: ProviderHost.ProviderHostShape; readonly fileSystem: FileSystem.FileSystem; readonly modelCatalog?: Effect.Effect>; readonly createHost?: typeof createMuseSdkHost; readonly requestTimeoutMs?: number; - readonly nativeEventLogger?: EventNdjsonLogger; + readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */ readonly continuationRequests?: { readonly offer: ( @@ -219,7 +197,7 @@ export interface MuseAdapterV2Options { } interface ActiveTurn { - readonly input: ProviderAdapterV2TurnInput; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; providerTurn: OrchestrationV2ProviderTurn; readonly nativeId: string; /** Turns Muse started on its own while this run was active, which this run shows. */ @@ -267,21 +245,23 @@ const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "wo const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); /** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */ -export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapterV2Shape { +export function makeMuseAdapterV2( + options: MuseAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2Shape { const { idAllocator } = options; const protocolError = (detail: string, payload?: unknown) => - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: MUSE_PROVIDER, detail, ...(payload === undefined ? {} : { payload }), }); - return ProviderAdapterV2.of({ + return ProviderAdapter.ProviderAdapterV2.of({ instanceId: options.instanceId, driver: MUSE_PROVIDER, getCapabilities: () => Effect.succeed(MuseProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), openSession: Effect.fn("MuseAdapterV2.openSession")(function* ( - input: ProviderAdapterV2OpenSessionInput, + input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, ) { if (!options.settings.enabled) return yield* protocolError("Muse Code is disabled"); if (input.runtimePolicy.interactionMode === "plan") @@ -307,8 +287,8 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte lastError: null, }; const events = yield* Queue.unbounded< - ProviderAdapterV2Event, - ProviderAdapterV2Error | Cause.Done + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterV2Error | Cause.Done >(); type Inbox = | { type: "notification"; method: string; params: unknown; epoch: number } @@ -344,7 +324,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte let wake: | { readonly nativeId: string; readonly events: Array<[string, unknown]> } | undefined; - const emit = (event: ProviderAdapterV2Event) => + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); const decode = (schema: Schema.Codec, data: unknown) => Schema.decodeUnknownEffect(schema)(data).pipe( @@ -1400,7 +1380,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte ).pipe( Effect.mapError( (error) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: MUSE_PROVIDER, providerSessionId: input.providerSessionId, cause: error.cause, @@ -1472,7 +1452,9 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte ); yield* launchHost(); - const register = Effect.fnUntraced(function* (args: ProviderAdapterV2EnsureThreadInput) { + const register = Effect.fnUntraced(function* ( + args: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { if (broken || closed) return yield* protocolError("Muse host is unavailable; reopen the session"); if (active) @@ -1592,7 +1574,9 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte ), ); }); - const prompt = Effect.fnUntraced(function* (message: ProviderAdapterV2TurnMessage) { + const prompt = Effect.fnUntraced(function* ( + message: ProviderAdapter.ProviderAdapterV2TurnMessage, + ) { const parts: SendUserTurnOptions["input"] = []; const text = providerMessageTextWithAttachmentPaths({ text: message.text, @@ -1635,7 +1619,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte ); }); const start = Effect.fnUntraced(function* ( - turnInput: ProviderAdapterV2TurnInput, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, compact = false, ) { if (!validateThread(turnInput.providerThread) || broken || closed) @@ -1783,7 +1767,10 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte // Only messages are read back; T3 owns turn and item history. const snapshot = Effect.fnUntraced(function* ( current: OrchestrationV2ProviderThread, - ): Effect.fn.Return { + ): Effect.fn.Return< + ProviderAdapter.ProviderAdapterV2ThreadSnapshot, + ProviderAdapter.ProviderAdapterV2Error + > { const result = yield* request("session/read", { excludeItems: false }, false).pipe( Effect.flatMap((value) => decode(MuseSessionResult, value)), ); @@ -1821,7 +1808,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte providerPayload: items, }; }); - const runtime: ProviderAdapterV2SessionRuntime = { + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: options.instanceId, driver: MUSE_PROVIDER, providerSessionId: input.providerSessionId, @@ -1836,7 +1823,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterEnsureThreadError({ + new ProviderAdapter.ProviderAdapterEnsureThreadError({ driver: MUSE_PROVIDER, threadId: args.threadId, cause, @@ -1853,7 +1840,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterResumeThreadError({ + new ProviderAdapter.ProviderAdapterResumeThreadError({ driver: MUSE_PROVIDER, providerSessionId: input.providerSessionId, providerThreadId: args.providerThread.id, @@ -1866,7 +1853,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: MUSE_PROVIDER, threadId: args.threadId, providerThreadId: args.providerThread.id, @@ -1897,7 +1884,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterSteerRunError({ + new ProviderAdapter.ProviderAdapterSteerRunError({ driver: MUSE_PROVIDER, providerThreadId: args.providerThread.id, providerTurnId: args.providerTurnId, @@ -1946,7 +1933,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterInterruptError({ + new ProviderAdapter.ProviderAdapterInterruptError({ driver: MUSE_PROVIDER, providerThreadId: args.providerThread.id, providerTurnId: args.providerTurnId, @@ -1999,7 +1986,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte }).pipe( Effect.mapError( (cause) => - new ProviderAdapterRuntimeRequestResponseError({ + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: MUSE_PROVIDER, requestId: args.requestId, cause, @@ -2014,7 +2001,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte commands.withPermits(1), Effect.mapError( (cause) => - new ProviderAdapterReadThreadSnapshotError({ + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ driver: MUSE_PROVIDER, providerThreadId: args.providerThread.id, cause, @@ -2025,7 +2012,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte // portable context handoff for forks and does not rewind Muse conversations. rollbackThread: (args) => Effect.fail( - new ProviderAdapterRollbackThreadError({ + new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: MUSE_PROVIDER, providerThreadId: args.providerThread.id, checkpointId: args.target.checkpointId, @@ -2034,7 +2021,7 @@ export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapte ), forkThread: (args) => Effect.fail( - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: MUSE_PROVIDER, providerThreadId: args.sourceProviderThread.id, cause: "Muse Code does not support native forks in T3 Code.", diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 87f6b2818907..8b9b5b1fa967 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { makeMuseTextGeneration } from "./textGeneration.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; @@ -42,7 +42,7 @@ const decodeMuseSettings = Schema.decodeSync(MuseSettings); export type MuseDriverEnv = | IdAllocator.IdAllocatorV2 - | ProviderHost + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient @@ -60,7 +60,7 @@ export const MuseDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const { cwd } = host.paths; const idAllocator = yield* IdAllocator.IdAllocatorV2; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 65a01a403095..19a32596ee0e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -33,16 +33,12 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import { makePiAdapterV2, @@ -68,7 +64,7 @@ const FAKE_SESSION_FILE = "/fake/.pi/agent/sessions/--workspace--/0001_abc.jsonl /** Deliberately outside the valid pid range so a group-kill can never land. */ const FAKE_PID = 999_999_999; -const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ +const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: null, @@ -331,7 +327,7 @@ const makeAdapter = Effect.fnUntraced(function* ( continuationRequests?: PiAdapterV2Options["continuationRequests"], ) { const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const spawner = forkFake === undefined @@ -383,12 +379,12 @@ const openRuntime = Effect.fnUntraced(function* ( modelSelection: modelSelection(model), runtimePolicy, }); - const emitted = yield* Queue.unbounded(); + const emitted = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(emitted, event)), Effect.forkScoped, ); - const takeEvent = (predicate: (event: ProviderAdapterV2Event) => boolean) => + const takeEvent = (predicate: (event: ProviderAdapter.ProviderAdapterV2Event) => boolean) => Effect.gen(function* () { while (true) { const event = yield* Queue.take(emitted); @@ -426,7 +422,7 @@ const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THR }); const startTurn = Effect.fnUntraced(function* ( - runtime: ProviderAdapterV2SessionRuntime, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, providerThread: OrchestrationV2ProviderThread, model = "default", attachments: ReadonlyArray = [], @@ -832,8 +828,9 @@ describe("PiAdapterV2", () => { }); yield* fake.emit({ type: "agent_start" }); yield* Queue.take(offers); - const requests: Array> = - []; + const requests: Array< + Extract + > = []; fake.deferNextModelSelection(); const starting = yield* startTurn( runtime, diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 73e1f64953c5..06f702c7eb31 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -60,7 +60,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; -import { ProviderHost, type ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { @@ -231,7 +231,7 @@ export interface PiAdapterV2Options { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly host: ProviderHostShape; + readonly host: ProviderHost.ProviderHostShape; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -3201,7 +3201,7 @@ export type PiAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 - | ProviderHost; + | ProviderHost.ProviderHost; export const PiAdapterV2Driver: ProviderAdapterDriver = { driverKind: PI_DRIVER_KIND, @@ -3213,7 +3213,7 @@ export const PiAdapterV2Driver: ProviderAdapterDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const { cwd } = host.paths; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index 1b3e74922eb5..6c9a9e113472 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -1,12 +1,12 @@ /** - * A `ProviderHost` for driver and adapter tests. Its directories live in a + * A `ProviderHost.ProviderHost` for driver and adapter tests. Its directories live in a * scoped temp directory, settings are fixed, and background work always runs * unless the test says otherwise. * * @module provider-testing/host */ import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -23,9 +23,9 @@ export interface TestProviderHostOptions { export const layerTestProviderHost = ( options: TestProviderHostOptions = {}, -): Layer.Layer => +): Layer.Layer => Layer.effect( - ProviderHost, + ProviderHost.ProviderHost, Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -39,7 +39,7 @@ export const layerTestProviderHost = ( yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); } const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; - return ProviderHost.of({ + return ProviderHost.ProviderHost.of({ paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, settings: { get: Effect.succeed(settings), From 456930a09db29efdd3d4a397fe214e9ede2d6975 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:29:17 -0700 Subject: [PATCH 044/377] fix(auth): show connection permissions and enforce session lifetime (#17370) Co-authored-by: Bear Huddleston --- .../settings/EnvironmentRoutesSection.tsx | 37 ++++- apps/server/src/auth/SessionStore.test.ts | 53 ++++++ apps/server/src/auth/SessionStore.ts | 33 ++++ apps/server/src/ws.ts | 12 +- .../settings/ConnectionsSettings.tsx | 157 +++++++----------- .../settings/EnvironmentRoutesList.tsx | 26 +-- .../settings/SessionPermissions.tsx | 38 +++++ .../settings/scopedSettings.test.ts | 20 ++- .../src/components/settings/scopedSettings.ts | 29 +++- .../components/settings/settingsLayout.tsx | 12 +- .../components/settings/useScopedSettings.ts | 13 +- apps/web/src/hooks/useSettings.sync.test.tsx | 50 +++++- apps/web/src/hooks/useSettings.ts | 79 +++++++-- docs/user/remote-access.md | 7 + packages/shared/package.json | 4 + packages/shared/src/authScopeOptions.ts | 106 ++++++++++++ 16 files changed, 536 insertions(+), 140 deletions(-) create mode 100644 apps/web/src/components/settings/SessionPermissions.tsx create mode 100644 packages/shared/src/authScopeOptions.ts diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 7e5021153a61..69cc5b35df45 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -9,7 +9,9 @@ import { connectionRoutes, isLearned, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; +import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; @@ -56,6 +58,9 @@ export function EnvironmentRoutesSection({ }) { const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); + const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const [permissionsOpen, setPermissionsOpen] = useState(false); const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); const [editing, setEditing] = useState(false); @@ -146,6 +151,36 @@ export function EnvironmentRoutesSection({ ) : undefined } > + setPermissionsOpen((open) => !open)} + /> + {permissionsOpen ? ( + + {activeRouteId !== null && + sessionResult._tag !== "Failure" && + !sessionResult.waiting && + session?.authenticated ? ( + <> + + Applies to the route marked In use. Other routes may have different permissions and + have not been checked. + + {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => ( + + {title}: {sessionGrantsScope(session, scope) ? "Allowed" : "Not granted"} + + ))} + + ) : ( + + Permissions not checked. Connect to this environment to view this session’s + permissions. + + )} + + ) : null} {routes.map((route, index) => { const id = connectionRouteId(route.target); // Rows between the lifted row and its drop slot shift to make room. diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 374a51be6872..05da2ac755de 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -3,6 +3,8 @@ import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -113,6 +115,57 @@ const layerFailingSessionLookupCredential = Layer.effect( ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)( + "invalidates every connection for a session on %s and leaves other sessions active", + (operation) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const owner = yield* sessions.issue({ subject: "owner" }); + const client = yield* sessions.issue(relaySessionInput); + const ownerInvalidated = yield* Deferred.make(); + yield* sessions + .awaitInvalidation(owner.sessionId) + .pipe( + Effect.andThen(Deferred.succeed(ownerInvalidated, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + const first = yield* sessions + .awaitInvalidation(client.sessionId) + .pipe(Effect.forkScoped({ startImmediately: true })); + const second = yield* sessions + .awaitInvalidation(client.sessionId) + .pipe(Effect.forkScoped({ startImmediately: true })); + if (operation === "revoke") yield* sessions.revoke(client.sessionId); + else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId); + else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId }); + yield* Fiber.join(first); + yield* Fiber.join(second); + expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false); + // A connection whose upgrade authenticated just before revocation must also stop. + yield* sessions.awaitInvalidation(client.sessionId); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + + it.effect("expires a connected session at its credential deadline", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const client = yield* sessions.issue(relaySessionInput); + yield* sessions.markConnected(client.sessionId); + const invalidated = yield* Deferred.make(); + const watcher = yield* sessions + .awaitInvalidation(client.sessionId) + .pipe( + Effect.andThen(Deferred.succeed(invalidated, undefined)), + Effect.forkScoped({ startImmediately: true }), + ); + yield* TestClock.adjust(Duration.minutes(59)); + expect(yield* Deferred.isDone(invalidated)).toBe(false); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(watcher); + yield* sessions.awaitInvalidation(client.sessionId); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), + ); + it.effect("keys remote cookies by environment identity instead of state directory", () => Effect.gen(function* () { const cookieName = (stateDir: string, environmentId: EnvironmentId) => diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 4db7e95e3066..9570785d9f93 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -407,6 +407,9 @@ export class SessionStore extends Context.Service< SessionCredentialInternalError >; readonly streamChanges: Stream.Stream; + readonly awaitInvalidation: ( + sessionId: AuthSessionId, + ) => Effect.Effect; readonly revoke: ( sessionId: AuthSessionId, ) => Effect.Effect; @@ -1047,6 +1050,35 @@ export const make = Effect.gen(function* () { return revokedSessionIds.length; }); + const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) { + return yield* Effect.scoped( + Effect.gen(function* () { + // Subscribe before reading: revocation can race with the WebSocket upgrade. + const subscription = yield* PubSub.subscribe(changesPubSub); + const row = yield* authSessions + .getById({ sessionId }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId, cause }), + ), + ); + if (Option.isNone(row) || row.value.revokedAt !== null) return; + const now = yield* DateTime.now; + const remaining = row.value.expiresAt.epochMilliseconds - now.epochMilliseconds; + if (remaining <= 0) return; + yield* Effect.raceFirst( + Stream.fromSubscription(subscription).pipe( + Stream.filter( + (change) => change.type === "clientRemoved" && change.sessionId === sessionId, + ), + Stream.runHead, + ), + Effect.sleep(Duration.millis(remaining)), + ); + }), + ); + }); + return SessionStore.of({ cookieName, legacyCookieName, @@ -1055,6 +1087,7 @@ export const make = Effect.gen(function* () { issueWebSocketToken, verifyWebSocketToken, listActive, + awaitInvalidation, get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index eee2755fcf08..31b07cea2baf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3224,7 +3224,17 @@ export const layer = Layer.unwrap( ); return yield* Effect.acquireUseRelease( sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, + () => + Effect.raceFirst( + rpcWebSocketHttpEffect, + sessions.awaitInvalidation(session.sessionId).pipe( + Effect.as(HttpServerResponse.empty()), + Effect.catchTags({ + SessionCredentialVerificationError: (error) => + failEnvironmentInternal("internal_error", error), + }), + ), + ), () => sessions.markDisconnected(session.sessionId), ); }).pipe( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 625efb562f97..a8d9d961a6c8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,3 +1,5 @@ +import { SessionPermissions } from "./SessionPermissions"; +import { AUTH_SCOPE_OPTIONS as PAIRING_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; import { ChevronRightIcon, ChevronsLeftRightEllipsisIcon, @@ -23,20 +25,13 @@ import { import { AuthAccessReadScope, AuthAccessWriteScope, - AuthSettingsWriteScope, - AuthProvidersManageScope, AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope, - AuthOrchestrationOperateScope, AuthOrchestrationReadScope, - AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, - AuthSourceControlWriteScope, AuthFilesystemReadScope, - AuthFilesystemWriteScope, AuthStandardClientScopes, - AuthTerminalOperateScope, AuthTerminalReadScope, type AuthClientSession, type AuthEnvironmentScope, @@ -231,93 +226,6 @@ function formatAccessTimestamp(value: string): string { return accessTimestampFormatter.format(parsed); } -const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ - readonly scope: AuthGrantScope; - readonly title: string; - readonly description: string; -}> = [ - { - scope: AuthOrchestrationReadScope, - title: "View environment", - description: "Read threads, status, checkpoints, and configuration.", - }, - { - scope: AuthOrchestrationOperateScope, - title: "Operate tasks", - description: "Start, update, and stop tasks.", - }, - { - scope: AuthSettingsWriteScope, - title: "Change environment settings", - description: "Edit environment preferences and keybindings.", - }, - { - scope: AuthProvidersManageScope, - title: "Manage providers", - description: "Configure, install, sign in to, and update providers and usage sources.", - }, - { - scope: AuthEnvironmentMaintainScope, - title: "Maintain environment", - description: "Update the server and control environment processes.", - }, - { - scope: AuthPreviewOperateScope, - title: "Control previews", - description: "Open browser previews and host browser automation.", - }, - { - scope: AuthDiagnosticsReadScope, - title: "View diagnostics and usage", - description: "Read process diagnostics, resource history, and usage totals.", - }, - { - scope: AuthTerminalReadScope, - title: "View terminals", - description: "Read existing terminal output and status.", - }, - { - scope: AuthTerminalOperateScope, - title: "Use terminals", - description: "Create terminals and send input to running shells.", - }, - { - scope: AuthSourceControlWriteScope, - title: "Change source control", - description: "Commit, push, manage branches and repositories, and change pull requests.", - }, - { - scope: AuthFilesystemReadScope, - title: "Read files", - description: "Browse host files, search workspaces, and inspect local changes.", - }, - { - scope: AuthFilesystemWriteScope, - title: "Write files", - description: "Edit workspace files and save plans to disk.", - }, - { - scope: AuthAccessReadScope, - title: "View access", - description: "Inspect pairing links and authorized clients.", - }, - { - scope: AuthAccessWriteScope, - title: "Manage access", - description: "Issue and revoke credentials for other clients.", - }, - { - scope: AuthRelayReadScope, - title: "View relay", - description: "Inspect managed relay connectivity.", - }, - { - scope: AuthRelayWriteScope, - title: "Manage relay", - description: "Change managed tunnel connectivity.", - }, -]; - function AccessScopeSummary({ scopes, label, @@ -1586,6 +1494,7 @@ function SavedBackendListRow({ onAddRoute, }: SavedBackendListRowProps) { const [routesOpen, setRoutesOpen] = useState(false); + const [permissionsOpen, setPermissionsOpen] = useState(false); const environmentId = environment.environmentId; const unsupported = environment.connection.phase === "unsupported"; const enabled = environment.entry.enabled && !unsupported; @@ -1753,6 +1662,25 @@ function SavedBackendListRow({ )} /> + + · + + } below={ @@ -1763,12 +1691,26 @@ function SavedBackendListRow({ ) : null } detail={ - routesOpen ? ( - onAddRoute(environment)} - /> - ) : null + <> + {routesOpen && ( + onAddRoute(environment)} + /> + )} + {permissionsOpen && ( +
+ +
+ )} + } > {unsupported && @@ -3774,6 +3716,21 @@ export function ConnectionsSettings() { ) : null} ) : null} + {primaryEnvironment ? ( +
+ + Permissions + + + +
+ ) : null} {canReadAccess || canWriteAccess ? ( diff --git a/apps/web/src/components/settings/EnvironmentRoutesList.tsx b/apps/web/src/components/settings/EnvironmentRoutesList.tsx index 9bf096688006..d2cb166f9e44 100644 --- a/apps/web/src/components/settings/EnvironmentRoutesList.tsx +++ b/apps/web/src/components/settings/EnvironmentRoutesList.tsx @@ -103,7 +103,7 @@ export function EnvironmentRoutesList({ }; return ( -
+
-
+

{label} @@ -199,6 +189,16 @@ function SortableRouteRow({

) : null}
+ {removable ? ( +

+ {routeContext + ? "These permissions apply to your active route. Other routes may have different permissions." + : "These permissions apply to this client’s current connection."} +

+
    + {AUTH_SCOPE_OPTIONS.map(({ scope, title }) => ( +
  • + {title} + + {sessionGrantsScope(session.data!, scope) ? "Allowed" : "Not granted"} + +
  • + ))} +
+
+ ) : ( +

+ Permissions not checked. Connect to this environment to view this session’s permissions. +

+ ); +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 7e15b4e5a26f..800ff48360a9 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -1,11 +1,13 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentAuthorizationError, EnvironmentId, ProjectId, type ServerSettings, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as Cause from "effect/Cause"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; @@ -410,7 +412,15 @@ describe("scoped settings writes", () => { const persistServer = vi .fn() .mockResolvedValueOnce({ _tag: "Success" }) - .mockResolvedValueOnce({ _tag: "Failure" }) + .mockResolvedValueOnce({ + _tag: "Failure", + cause: Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: "settings:write", + message: "This connection lacks permission to change settings.", + }), + ), + }) .mockRejectedValueOnce(new Error("Disconnected during save")) .mockResolvedValueOnce({ _tag: "Success" }); const result = await persistScopedSettingsPatch( @@ -419,6 +429,14 @@ describe("scoped settings writes", () => { vi.fn(), ); expect(result.savedEnvironmentCount).toBe(2); + expect(result.savedEnvironments.map(({ label }) => label)).toEqual([ + laptop.label, + fourth.label, + ]); + expect(result.failedEnvironments.map(({ message }) => message)).toEqual([ + "This connection lacks permission to change settings.", + "Disconnected during save", + ]); expect(result.failedEnvironments.map(({ label }) => label)).toEqual([ server.label, third.label, diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index e1e884632358..537c3bb381b3 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -19,6 +19,7 @@ import { type ProjectSettingSource, } from "@t3tools/shared/projectSettings"; import * as Equal from "effect/Equal"; +import * as Cause from "effect/Cause"; import type { ResolvedSettingsScope } from "./settingsScope"; @@ -394,7 +395,7 @@ export async function persistScopedSettingsPatch( persistServer: (input: { environmentId: EnvironmentId; input: { patch: ServerSettingsPatch }; - }) => Promise<{ readonly _tag: "Success" | "Failure" }>, + }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause }>, persistClient: (patch: ClientSettingsPatch) => void, ) { if (plan.hasClientWrite) persistClient(plan.clientPatch); @@ -403,12 +404,32 @@ export async function persistScopedSettingsPatch( persistServer({ environmentId, input: { patch } }), ), ); - const failedEnvironments = plan.serverWrites.filter((_, index) => { + const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { const result = results[index]; - return result?.status !== "fulfilled" || result.value._tag === "Failure"; + if (result?.status === "fulfilled" && result.value._tag === "Success") return []; + const error: unknown = + result?.status === "rejected" + ? result.reason + : result?.status === "fulfilled" && result.value.cause + ? Cause.squash(result.value.cause) + : undefined; + return [ + { + ...environment, + message: + error instanceof Error + ? error.message + : "The save failed. Try reconnecting and saving again.", + }, + ]; + }); + const savedEnvironments = plan.serverWrites.filter((_, index) => { + const result = results[index]; + return result?.status === "fulfilled" && result.value._tag === "Success"; }); return { failedEnvironments, - savedEnvironmentCount: plan.serverWrites.length - failedEnvironments.length, + savedEnvironments, + savedEnvironmentCount: savedEnvironments.length, }; } diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 99a47cb5aa65..381ba5c54f7e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,6 +1,6 @@ import { SettingsGroup } from "./SettingsGroup"; import { AuthSettingsWriteScope } from "@t3tools/contracts"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { InfoIcon, Undo2Icon } from "lucide-react"; import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; @@ -290,6 +290,7 @@ export function SettingsRow({ const primarySettingsAvailable = usePrimarySettingsAvailable(); const context = useOptionalSettingsScope(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironment = usePrimaryEnvironment(); const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); const writableIds = useEnvironmentsWithScope( context?.connectedEnvironments ?? [], @@ -395,7 +396,14 @@ export function SettingsRow({ unavailable && control ? inertControl( !canWriteSettings - ? "This connection does not have permission to change environment settings." + ? `This connection lacks permission to change settings on ${ + context + ? context.connectedEnvironments + .filter((target) => !writableIds.has(target.environmentId)) + .map((target) => target.label) + .join(", ") || "the selected environment" + : (primaryEnvironment?.label ?? "the primary environment") + }.` : context ? "Reconnect the selected environment to change this setting." : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 743eb4ec4392..4bed07267b13 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -83,20 +83,27 @@ function useRunScopedPlan() { Cause.fail( new EnvironmentAuthorizationError({ requiredScope: missing, - message: "This connection cannot change these settings.", + message: "This connection lacks permission to change these settings.", }), ), ); return persistServer(request); }, persistClientSettingsPatch, - ).then(({ failedEnvironments, savedEnvironmentCount }) => { + ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => { if (failedEnvironments.length === 0) return; toastManager.add({ type: "error", title: savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved", - description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`, + description: [ + ...failedEnvironments.map( + ({ label, message }) => `Could not save on ${label}: ${message}`, + ), + ...(savedEnvironmentCount > 0 + ? [`Saved on ${savedEnvironments.map(({ label }) => label).join(", ")}.`] + : []), + ].join("\n"), }); }); }, diff --git a/apps/web/src/hooks/useSettings.sync.test.tsx b/apps/web/src/hooks/useSettings.sync.test.tsx index fd46cd8962d7..129c5f5469b4 100644 --- a/apps/web/src/hooks/useSettings.sync.test.tsx +++ b/apps/web/src/hooks/useSettings.sync.test.tsx @@ -122,6 +122,54 @@ afterEach(async () => { }); describe("shared settings writes", () => { + it("waits for the remaining target before reporting a partial save", async () => { + let finishRemote = () => {}; + const remote = new Promise((resolve) => { + finishRemote = () => resolve(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); + }); + state.persist.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Permission denied"))), + ); + state.persist.mockReturnValueOnce(remote); + await mountEditor(); + await act(async () => saveSharedSettings()); + expect(state.toast).not.toHaveBeenCalled(); + await act(async () => finishRemote()); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Setting saved on some environments", + description: "Could not save on primary: Permission denied\nSaved on remote.", + }); + }); + + it("keeps a single-target failure specific to that environment", async () => { + state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); + state.persist.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Permission denied"))), + ); + await mountEditor(); + await act(async () => saveSharedSettings()); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Setting not saved", + description: "Could not save on primary: Permission denied", + }); + }); + + it("names the failed environment when a server rejects a save after the grant check", async () => { + state.persist.mockResolvedValueOnce(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); + state.persist.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Permission denied"))), + ); + await mountEditor(); + await act(async () => saveSharedSettings()); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Setting saved on some environments", + description: "Could not save on remote: Permission denied\nSaved on primary.", + }); + }); + it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { await mountEditor(); saveSharedSettings(); @@ -193,7 +241,7 @@ describe("shared settings writes", () => { expect(state.toast).toHaveBeenCalledExactlyOnceWith({ type: "warning", title: "Setting not saved", - description: "This connection does not have permission to change these settings.", + description: "This connection lacks permission to change settings on remote.", }); } }, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 04fab1c1d1c0..d61abb34f553 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -29,6 +29,10 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { filterSharedServerPatch, splitSharedServerPatch, @@ -469,11 +473,29 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray[0]) => { + const result = await persist(request); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const label = + environments.find((environment) => environment.environmentId === request.environmentId) + ?.label ?? request.environmentId; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Setting not saved", + description: `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`, + }); + } + return result; + }, + [environments, persist], + ); const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { @@ -488,7 +510,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { toastManager.add({ type: "warning", title: "Setting not saved", - description: "This connection does not have permission to change these settings.", + description: `This connection lacks permission to change settings on ${environments.find((target) => target.environmentId === environmentId)?.label ?? "the selected environment"}.`, }); } if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { @@ -518,8 +540,8 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (environmentId) { targets.add(environmentId); } - let wroteToTarget = false; - let permissionDenied = false; + const writes: Array[0]> = []; + const deniedLabels: string[] = []; for (const targetId of targets) { const target = environments.find((candidate) => candidate.environmentId === targetId); const targetPatch = filterSharedServerPatch( @@ -537,23 +559,52 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { readEnvironmentScope(targetId, scope), ) ) { - permissionDenied = true; + deniedLabels.push(target?.label ?? targetId); continue; } - wroteToTarget = true; - void persistServerSettings({ + writes.push({ environmentId: targetId, input: { patch: targetPatch }, }); } - if (!wroteToTarget) { + if (writes.length === 0) { warnUnsaved( - permissionDenied - ? "This connection does not have permission to change these settings." + deniedLabels.length > 0 + ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.` : targets.size > 0 ? "Update older servers to save this setting." : undefined, ); + } else { + void Promise.all( + writes.map(async (request) => ({ + label: + environments.find((target) => target.environmentId === request.environmentId) + ?.label ?? request.environmentId, + result: await persist(request), + })), + ).then((outcomes) => { + const failures = outcomes.flatMap(({ label, result }) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; + const error = squashAtomCommandFailure(result); + return [ + `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`, + ]; + }); + if (failures.length === 0) return; + const saved = outcomes + .filter(({ result }) => result._tag === "Success") + .map(({ label }) => label); + toastManager.add({ + type: "error", + title: + saved.length > 0 ? "Setting saved on some environments" : "Setting not saved", + description: [ + ...failures, + ...(saved.length > 0 ? [`Saved on ${saved.join(", ")}.`] : []), + ].join("\n"), + }); + }); } } } @@ -561,7 +612,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { void persistClientSettingsPatch(clientPatch); } }, - [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], + [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds], ); return updateSettings; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 87c8ee9bea67..73062245c9ba 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -91,6 +91,13 @@ or remove it. On mobile, open the machine under **Settings → Environments** an choose **Edit**. Signing out of T3 Connect removes only that route; a machine you can still reach another way stays saved. +Open **Permissions** next to **Routes** in web or desktop, or **Your permissions** +in the mobile route details, to see what your current connection can do on that +environment. For a remote environment, this is in its route details. Permissions +shown there apply only to the route marked **In use**; other routes are not +checked. Direct pairing and T3 Connect have separate sessions and may grant +different permissions. + ### Balance new threads across machines Auto balance is off by default. On web and desktop, enable it in diff --git a/packages/shared/package.json b/packages/shared/package.json index 697dbeb81e37..ec9277fc0cd4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./authScopeOptions": { + "types": "./src/authScopeOptions.ts", + "import": "./src/authScopeOptions.ts" + }, "./orchestrationV2ThreadError": { "types": "./src/orchestrationV2ThreadError.ts", "import": "./src/orchestrationV2ThreadError.ts" diff --git a/packages/shared/src/authScopeOptions.ts b/packages/shared/src/authScopeOptions.ts new file mode 100644 index 000000000000..d443e50326d2 --- /dev/null +++ b/packages/shared/src/authScopeOptions.ts @@ -0,0 +1,106 @@ +import { + AuthAccessReadScope, + AuthAccessWriteScope, + AuthDiagnosticsReadScope, + AuthEnvironmentMaintainScope, + AuthFilesystemReadScope, + AuthFilesystemWriteScope, + type AuthGrantScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthPreviewOperateScope, + AuthProvidersManageScope, + AuthRelayReadScope, + AuthRelayWriteScope, + AuthSettingsWriteScope, + AuthSourceControlWriteScope, + AuthTerminalOperateScope, + AuthTerminalReadScope, +} from "@t3tools/contracts"; + +export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{ + readonly scope: AuthGrantScope; + readonly title: string; + readonly description: string; +}> = [ + { + scope: AuthOrchestrationReadScope, + title: "View environment", + description: "Read threads, status, checkpoints, and configuration.", + }, + { + scope: AuthOrchestrationOperateScope, + title: "Operate tasks", + description: "Start, update, and stop tasks.", + }, + { + scope: AuthSettingsWriteScope, + title: "Change environment settings", + description: "Edit environment preferences and keybindings.", + }, + { + scope: AuthProvidersManageScope, + title: "Manage providers", + description: "Configure, install, sign in to, and update providers and usage sources.", + }, + { + scope: AuthEnvironmentMaintainScope, + title: "Maintain environment", + description: "Update the server and control environment processes.", + }, + { + scope: AuthPreviewOperateScope, + title: "Control previews", + description: "Open browser previews and host browser automation.", + }, + { + scope: AuthDiagnosticsReadScope, + title: "View diagnostics and usage", + description: "Read process diagnostics, resource history, and usage totals.", + }, + { + scope: AuthTerminalReadScope, + title: "View terminals", + description: "Read existing terminal output and status.", + }, + { + scope: AuthTerminalOperateScope, + title: "Use terminals", + description: "Create terminals and send input to running shells.", + }, + { + scope: AuthSourceControlWriteScope, + title: "Change source control", + description: "Commit, push, manage branches and repositories, and change pull requests.", + }, + { + scope: AuthFilesystemReadScope, + title: "Read files", + description: "Browse host files, search workspaces, and inspect local changes.", + }, + { + scope: AuthFilesystemWriteScope, + title: "Write files", + description: "Edit workspace files and save plans to disk.", + }, + { + scope: AuthAccessReadScope, + title: "View access", + description: "Inspect pairing links and authorized clients.", + }, + { + scope: AuthAccessWriteScope, + title: "Manage access", + description: "Issue and revoke credentials for other clients.", + }, + { + scope: AuthRelayReadScope, + title: "View relay", + description: "Inspect managed relay connectivity.", + }, + { + scope: AuthRelayWriteScope, + title: "Manage relay", + description: "Change managed tunnel connectivity.", + }, +]; From e62161bc5b53389a462d066836d47d25df0f4134 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:30:57 -0700 Subject: [PATCH 045/377] refactor(provider-opencode): move OpenCode into its own provider package (#17345) Co-authored-by: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/components/ProviderIcon.tsx | 9 --- apps/mobile/src/lib/providerClients.ts | 3 +- apps/server/package.json | 4 +- apps/server/src/cli/theme.ts | 2 +- apps/server/src/device/LocalDeviceHost.ts | 2 +- apps/server/src/keybindings.ts | 2 +- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../AcpRegistryOrchestratorV2.live.test.ts | 4 +- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 2 +- .../Adapters/ClaudeAdapterV2.ts | 2 +- .../Adapters/CodexAdapterV2.ts | 2 +- .../Adapters/CursorAdapterV2.ts | 2 +- .../Adapters/OpenCode2AdapterV2.test.ts | 7 +- .../Adapters/OpenCode2AdapterV2.testkit.ts | 42 +++-------- .../Adapters/OpenCodeAdapterV2.testkit.ts | 19 ++--- .../CursorOrchestratorV2.live.test.ts | 4 +- .../GrokOrchestratorV2.live.test.ts | 4 +- ...penCode2OrchestratorV2.integration.test.ts | 2 +- .../OpenCode2OrchestratorV2.live.test.ts | 6 +- .../src/orchestration-v2/Orchestrator.ts | 2 +- .../SubagentProjection.test.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- apps/server/src/provider/ModelManifest.ts | 2 +- .../OpenCode2Client.events.test.ts | 4 +- .../provider/ProviderInstanceRegistry.test.ts | 8 +-- .../src/provider/ProviderRegistry.test.ts | 4 +- .../src/provider/acp/AcpNativeLogging.ts | 2 +- .../acp/AcpRegistryAuthenticationState.ts | 2 +- apps/server/src/provider/builtInDrivers.ts | 2 +- .../src/provider/providerStatusCache.ts | 2 +- apps/server/src/server.ts | 17 ++++- apps/server/src/serverRuntimeState.ts | 2 +- apps/server/src/serverSettings.test.ts | 2 +- apps/server/src/serverSettings.ts | 2 +- apps/server/src/telemetry/Identify.ts | 2 +- .../OpenCode2TextGeneration.test.ts | 10 ++- apps/server/src/usage/UsageService.ts | 2 +- apps/web/package.json | 1 + apps/web/src/components/Icons.tsx | 26 ------- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../components/chat/ProviderInstanceIcon.tsx | 12 +--- .../components/chat/ProviderPackageIcon.tsx | 2 + .../components/settings/providerDriverMeta.ts | 8 +-- docs/internals/adding-a-provider.md | 5 +- docs/internals/providers.md | 8 +-- package.json | 2 +- packages/contracts/src/settings.ts | 52 -------------- packages/provider-core/package.json | 8 +++ .../src/server/nativeProtocolLogging.ts | 0 .../src/server/subagentProjection.ts | 0 packages/provider-opencode/package.json | 68 ++++++++++++++++++ packages/provider-opencode/src/client.ts | 24 +++++++ packages/provider-opencode/src/server.ts | 8 +++ .../server/OpenCodeRuntime.cliParsers.test.ts | 43 +++++------- .../OpenCodeRuntime.environment.test.ts | 2 +- .../server/OpenCodeRuntime.inventory.test.ts | 2 +- .../OpenCodeRuntime.permissions.test.ts | 10 +-- .../src/server/OpenCodeRuntime.ts | 4 +- .../src/server}/OpenCodeServerLedger.test.ts | 2 +- .../src/server}/OpenCodeServerLedger.ts | 26 +++---- .../src/server}/OpenCodeServerOwner.test.ts | 2 +- .../src/server}/OpenCodeServerOwner.ts | 4 +- .../src/server/adapter.test.ts | 63 +++++++++-------- .../provider-opencode/src/server/adapter.ts | 64 ++++++++--------- .../src/server/driver.test.ts | 30 +++----- .../provider-opencode/src/server/driver.ts | 62 ++++++++--------- .../src/server/probeResponses.fixture.ts | 0 .../src/server/status.test.ts | 14 ++-- .../provider-opencode/src/server/status.ts | 6 +- .../src/server/textGeneration.test.ts | 27 +++----- .../src/server/textGeneration.ts | 15 ++-- .../provider-opencode/src/server/toolItems.ts | 0 .../src/server/usageLimits.ts | 0 .../server/v2}/OpenCode2Client.live.test.ts | 0 .../src/server/v2}/OpenCode2Client.test.ts | 0 .../src/server/v2}/OpenCode2Client.ts | 2 +- .../server/v2}/OpenCode2Server.live.test.ts | 2 +- .../src/server/v2}/OpenCode2Server.test.ts | 2 +- .../src/server/v2}/OpenCode2Server.ts | 14 ++-- .../src/server/v2/adapter.ts | 69 +++++++++---------- .../src/server/v2/textGeneration.ts | 20 +++--- .../src/server/versionProbe.test.ts | 6 +- .../src/server/versionProbe.ts | 2 +- packages/provider-opencode/src/settings.ts | 66 ++++++++++++++++++ packages/provider-opencode/src/testing.ts | 13 ++++ packages/provider-opencode/tsconfig.json | 7 ++ packages/shared/package.json | 4 ++ .../shared}/src/atomicWrite.test.ts | 0 .../shared}/src/atomicWrite.ts | 0 pnpm-lock.yaml | 61 +++++++++++++--- scripts/release-smoke.ts | 1 + 92 files changed, 575 insertions(+), 486 deletions(-) rename apps/server/src/provider/{opencode2 => }/OpenCode2Client.events.test.ts (97%) rename apps/server/src/provider/NativeProtocolLogging.ts => packages/provider-core/src/server/nativeProtocolLogging.ts (100%) rename apps/server/src/orchestration-v2/SubagentProjection.ts => packages/provider-core/src/server/subagentProjection.ts (100%) create mode 100644 packages/provider-opencode/package.json create mode 100644 packages/provider-opencode/src/client.ts create mode 100644 packages/provider-opencode/src/server.ts rename apps/server/src/provider/opencodeRuntime.cliParsers.test.ts => packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts (89%) rename apps/server/src/provider/opencodeRuntime.environment.test.ts => packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts (99%) rename apps/server/src/provider/opencodeRuntime.inventory.test.ts => packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts (99%) rename apps/server/src/provider/opencodeRuntime.permissions.test.ts => packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts (89%) rename apps/server/src/provider/opencodeRuntime.ts => packages/provider-opencode/src/server/OpenCodeRuntime.ts (99%) rename {apps/server/src/provider => packages/provider-opencode/src/server}/OpenCodeServerLedger.test.ts (99%) rename {apps/server/src/provider => packages/provider-opencode/src/server}/OpenCodeServerLedger.ts (95%) rename {apps/server/src/provider => packages/provider-opencode/src/server}/OpenCodeServerOwner.test.ts (99%) rename {apps/server/src/provider => packages/provider-opencode/src/server}/OpenCodeServerOwner.ts (98%) rename apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts => packages/provider-opencode/src/server/adapter.test.ts (98%) rename apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts => packages/provider-opencode/src/server/adapter.ts (98%) rename apps/server/src/provider/Drivers/OpenCodeDriver.test.ts => packages/provider-opencode/src/server/driver.test.ts (90%) rename apps/server/src/provider/Drivers/OpenCodeDriver.ts => packages/provider-opencode/src/server/driver.ts (92%) rename apps/server/src/provider/testFixtures/opencodeProbeResponses.ts => packages/provider-opencode/src/server/probeResponses.fixture.ts (100%) rename apps/server/src/provider/OpenCodeProvider.test.ts => packages/provider-opencode/src/server/status.test.ts (98%) rename apps/server/src/provider/OpenCodeProvider.ts => packages/provider-opencode/src/server/status.ts (99%) rename apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts => packages/provider-opencode/src/server/textGeneration.test.ts (96%) rename apps/server/src/textGeneration/OpenCodeTextGeneration.ts => packages/provider-opencode/src/server/textGeneration.ts (95%) rename apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts => packages/provider-opencode/src/server/toolItems.ts (100%) rename apps/server/src/provider/openCodeUsageLimits.ts => packages/provider-opencode/src/server/usageLimits.ts (100%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Client.live.test.ts (100%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Client.test.ts (100%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Client.ts (99%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Server.live.test.ts (99%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Server.test.ts (99%) rename {apps/server/src/provider/opencode2 => packages/provider-opencode/src/server/v2}/OpenCode2Server.ts (94%) rename apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts => packages/provider-opencode/src/server/v2/adapter.ts (98%) rename apps/server/src/textGeneration/OpenCode2TextGeneration.ts => packages/provider-opencode/src/server/v2/textGeneration.ts (92%) rename apps/server/src/provider/opencodeVersionProbe.test.ts => packages/provider-opencode/src/server/versionProbe.test.ts (98%) rename apps/server/src/provider/opencodeVersionProbe.ts => packages/provider-opencode/src/server/versionProbe.ts (99%) create mode 100644 packages/provider-opencode/src/settings.ts create mode 100644 packages/provider-opencode/src/testing.ts create mode 100644 packages/provider-opencode/tsconfig.json rename {apps/server => packages/shared}/src/atomicWrite.test.ts (100%) rename {apps/server => packages/shared}/src/atomicWrite.ts (100%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 08307ad65c2a..02ba1b20ae85 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -71,6 +71,7 @@ "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 0645e6739818..dda6e6f3b544 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -138,15 +138,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "opencode") { - return ( - - - - - ); - } - // codex (and unknown drivers) return ( diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index 2d3ec1224992..11f7e13a3a78 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,10 +1,11 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { museClient } from "@t3tools/provider-muse/client"; +import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([museClient, piClient]); +const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index bb776149e4cd..11f202120f85 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,11 +38,9 @@ "@napi-rs/keyring": "^1.3.0", "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", - "@opencode/client": "2.0.23", - "@opencode/protocol": "2.0.23", - "@opencode/schema": "2.0.23", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts index 13f451b42363..2c05c00f9a4e 100644 --- a/apps/server/src/cli/theme.ts +++ b/apps/server/src/cli/theme.ts @@ -35,7 +35,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/cli"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import { MAX_THEME_FILE_BYTES, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 65ddf6895742..58179151877c 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,7 @@ import { HttpClient } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 3697b62cb403..332aa7134bd0 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -41,7 +41,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "./config.ts"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 78d4ee48e666..b218a76d1ab5 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -75,7 +75,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import { subagentResultForRun, delegatedTaskProgress, -} from "../orchestration-v2/SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { DispatchModeLimit, type DispatchModeRefusal, diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 2eb06f757052..4f2ea3e2f3d6 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 90255fe75a78..03a5c629b663 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -119,7 +119,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 50fab3e191db..4cd45b86df7f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -140,7 +140,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 5318971e89d4..c8b988289390 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -165,7 +165,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 53a1d260cc83..273564e786c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -73,7 +73,7 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 96b28d5e082e..ff87c347641e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -46,8 +46,11 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; -import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts"; +import { + OPENCODE_2_STILL_STOPPING, + OPENCODE_PROVIDER, + t3McpServerName, +} from "@t3tools/provider-opencode/testing"; import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts"; const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 466349cc37ff..2b009799cc53 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -20,22 +20,19 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as UrlParams from "effect/http/UrlParams"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCode2Client from "../../provider/opencode2/OpenCode2Client.ts"; -import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; +import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; +import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; -import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; import { OpenCodeReplayController, OpenCodeReplayTranscriptDecodeError, } from "./OpenCodeAdapterV2.testkit.ts"; -import * as OpenCode2AdapterV2 from "./OpenCode2AdapterV2.ts"; +import * as OpenCode2Adapter from "@t3tools/provider-opencode/server/v2/adapter"; export const OPENCODE2_HTTP_PROTOCOL = "opencode2-http.sse" as const; const BASE_URL = "http://opencode2.replay"; @@ -265,15 +262,12 @@ const makeReplayAdapter = ( ) => Effect.gen(function* () { const server = yield* replayServer(transcript, options); - return yield* OpenCode2AdapterV2.make(ProviderInstanceId.make("opencode")).pipe( + return yield* OpenCode2Adapter.make(ProviderInstanceId.make("opencode")).pipe( Effect.provideService(OpenCode2Server.OpenCode2Server, server), ); }); -const layerReplayServerConfig = (scenario: string) => - Layer.effect(ServerConfig.ServerConfig, makeReplayServerConfig(scenario).pipe(Effect.orDie)).pipe( - Layer.provide(NodeServices.layer), - ); +const layerReplayHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); function layerRegistry( transcript: OpenCode2ReplayTranscript, @@ -283,15 +277,7 @@ function layerRegistry( makeReplayAdapter(transcript, { external: true, ...options }).pipe( Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])), ), - ).pipe( - Layer.provide( - Layer.mergeAll( - layerReplayServerConfig(transcript.scenario), - IdAllocator.layer, - NodeServices.layer, - ), - ), - ); + ).pipe(Layer.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); } /** @@ -327,15 +313,7 @@ export const openCode2ReplayRuntime = ( cwd: "/work/opencode2", }, }); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerReplayServerConfig("opencode2_adapter"), - IdAllocator.layer, - NodeServices.layer, - ), - ), - ); + }).pipe(Effect.provide(Layer.mergeAll(layerReplayHost, IdAllocator.layer, NodeServices.layer))); export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< OpenCode2ReplayTranscript, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index aaf50f24a6ac..36d0e35ddced 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -6,22 +6,19 @@ import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; +import { OpenCodeAdapterV2Driver } from "@t3tools/provider-opencode/server"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; +import { type OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { OPENCODE_DEFAULT_INSTANCE_ID, OPENCODE_PROVIDER, OPENCODE_SDK_PROTOCOL, - OpenCodeAdapterV2Driver, -} from "./OpenCodeAdapterV2.ts"; +} from "@t3tools/provider-opencode/testing"; const OPENCODE_SDK_REPLAY_PROTOCOL = OPENCODE_SDK_PROTOCOL; @@ -457,10 +454,6 @@ function layerOpenCodeReplayRuntime(transcript: OpenCodeSdkReplayTranscript) { } function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkReplayTranscript) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromDrivers({ drivers: [OpenCodeAdapterV2Driver], configMap: { @@ -473,7 +466,7 @@ function layerOpenCodeProviderAdapterRegistryReplay(transcript: OpenCodeSdkRepla Layer.provide( Layer.mergeAll( layerOpenCodeReplayRuntime(transcript), - layerServerConfig, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), NodeServices.layer, IdAllocator.layer, Layer.succeed( diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index d6dc569db2b6..1c74ee18d7a9 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index b6b5eee97a0d..15d29d30f8ac 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -30,8 +30,8 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 873ca407a701..3b9fa218d052 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -27,7 +27,7 @@ import { OPENCODE2_HTTP_PROTOCOL, OpenCode2OrchestratorReplayHarness, } from "./Adapters/OpenCode2AdapterV2.testkit.ts"; -import { OPENCODE_PROVIDER } from "./Adapters/OpenCodeAdapterV2.ts"; +import { OPENCODE_PROVIDER } from "@t3tools/provider-opencode/testing"; import { provideDeterministicTestRuntime } from "./testkit/DeterministicRuntime.ts"; import type { OrchestratorV2ScenarioStep } from "./testkit/OrchestratorScenario.ts"; import { runOrchestratorV2ProviderReplayScenario } from "./testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 9f046abba25f..55d93ded6e6e 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -55,9 +55,9 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; -import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; +import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 54127e20bc7a..c85f11c56835 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -121,7 +121,7 @@ import { subagentResultForRun, delegatedTaskProgress, subagentThreadTitle, -} from "./SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { forkableSourceRunStatusError, isForkableSourceRunStatus, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index e2691e08e0db..59eca69cf35a 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -21,7 +21,7 @@ import { delegatedTaskProgress, subagentResultForRun, makeSubagentConversationArtifacts, -} from "./SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import { emptyProjection } from "./ProjectionStore.ts"; diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 8a6490ef798f..3e33d5b2de41 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -17,7 +17,7 @@ import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/Gro import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, -} from "./Adapters/OpenCodeAdapterV2.ts"; +} from "@t3tools/provider-opencode/server"; import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderAdapterDriver } from "@t3tools/provider-core/server/adapterDriver"; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 2f0f65cf5390..37c2bbec91b0 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -33,7 +33,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts b/apps/server/src/provider/OpenCode2Client.events.test.ts similarity index 97% rename from apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts rename to apps/server/src/provider/OpenCode2Client.events.test.ts index 0c4ae966e2d0..25f246d89acb 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.events.test.ts +++ b/apps/server/src/provider/OpenCode2Client.events.test.ts @@ -10,10 +10,10 @@ import { TestClock } from "effect/testing"; import { HttpClient, HttpClientResponse } from "effect/http"; import { describe } from "vite-plus/test"; -import * as OpenCode2Client from "./OpenCode2Client.ts"; +import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; const RECORDING = new URL( - "../../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", + "../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", import.meta.url, ); const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 8050e426dd6c..5c1bbb165822 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -33,7 +33,6 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, - type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -56,10 +55,11 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; +import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; import * as ModelManifest from "./ModelManifest.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 733bb59280cf..b76e079fc38a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -45,8 +45,8 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; import * as ServerConfig from "../config.ts"; diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index c909f5a007b8..a510c5f88b5a 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -10,7 +10,7 @@ import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, -} from "../NativeProtocolLogging.ts"; +} from "@t3tools/provider-core/server/nativeProtocolLogging"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts index dee416d776ca..986ed6d0ca72 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts @@ -12,7 +12,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; const decodeState = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })), diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 1690a998c5f4..8cfcc007e9c1 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,7 +26,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index a4b260f43bf6..ea9e7ff9ce7a 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -9,7 +9,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b6c0d99848ae..f91976f38ece 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,8 +43,8 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; -import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; -import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -614,7 +614,18 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( // the rewritten registry reads snapshots off the instance registry and // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layer))), + Layer.provideMerge( + OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return OpenCodeServerLedger.layer({ stateDir: config.stateDir }); + }), + ), + ), + ), + ), Layer.provideMerge(layerWorkspace), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 26535ae348a2..8e565b46e26b 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 1611b7dfddb9..4444a7851dff 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -28,7 +28,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0695f673650d..7536a74d6e02 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -53,7 +53,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; -import { writeFileStringAtomically } from "./atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index dcb59d8bb3fb..4009625d5752 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -8,7 +8,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; /** diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index eaed9ea6abd6..424764cd8f37 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -4,15 +4,13 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as ServerConfig from "../config.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts"; -import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; -import * as OpenCode2TextGeneration from "./OpenCode2TextGeneration.ts"; +import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server"; +import * as OpenCode2TextGeneration from "@t3tools/provider-opencode/server/v2/textGeneration"; import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts"; -const layer = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode2-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); +const layer = layerTestProviderHost().pipe(Layer.provideMerge(NodeServices.layer)); it.layer(layer)("OpenCode2TextGeneration", (it) => { it.effect("generates a title in a temporary session on the free tier and removes it", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 7b4269287f4b..76bdf21f377a 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -52,7 +52,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/web/package.json b/apps/web/package.json index 63a5f5c2ad35..372c956ec04e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-muse": "workspace:*", + "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7f38d65f2a14..be008bac3330 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -681,32 +681,6 @@ export const AntigravityIcon: Icon = (props) => ( ); -export const OpenCodeIcon: Icon = (props) => ( - - - - - - - - - - - - - -); - export const ACPRegistryIcon: Icon = ({ className, ...props }) => ( > = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, - [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, @@ -36,7 +27,6 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial` package, with - **Package exports.** `./settings` holds the instance settings schema, built with `makeProviderSettingsSchema` from contracts. `./client` exports a `ProviderClientDefinition` (label, settings schema, plain-data icon, badge) and must stay browser- and React Native-safe. - `./server` exports the `ProviderDriver` and its adapter driver. Settings live in - `providerInstances`; new providers are off by default. + `./server` exports the `ProviderDriver` and its adapter driver. `./testing` exposes internals + the server's replay testkits drive; testkits that need the orchestrator stay in the server. + Settings live in `providerInstances`; new providers are off by default. - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b8542cbbef38..6de371a95084 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -17,12 +17,12 @@ directory must not share one T3 MCP entry. - **1.x** uses one T3-managed chat server per thread, so threads cannot replace each other's connection. Catalog and text-generation work can share the - [instance-owned helper](../../apps/server/src/provider/OpenCodeServerOwner.ts), which closes - after an idle period. See the [1.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts). + [instance-owned helper](../../packages/provider-opencode/src/server/OpenCodeServerOwner.ts), which closes + after an idle period. See the [1.x adapter](../../packages/provider-opencode/src/server/adapter.ts). - **2.x** serves every directory from one - [server per instance](../../apps/server/src/provider/opencode2/OpenCode2Server.ts). Each thread + [server per instance](../../packages/provider-opencode/src/server/v2/OpenCode2Server.ts). Each thread registers its own `t3-code-` MCP entry, and session permission rules deny every other - thread's entry. See the [2.x adapter](../../apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts). + thread's entry. See the [2.x adapter](../../packages/provider-opencode/src/server/v2/adapter.ts). External OpenCode servers remain externally owned and can require an external restart to pick up configuration changes. OpenCode stores "always" approval grants for the whole project. Automatic diff --git a/package.json b/package.json index a345b307273d..e261ffedfb35 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e62f4ff578b7..a302412f179e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -907,58 +907,6 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( ); export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; -export const OpenCodeSettings = makeProviderSettingsSchema( - { - // Off by default (like Cursor and Grok): the binding is not yet stable - // enough to probe on every install. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("opencode").pipe( - Schema.annotateKey({ - title: "Binary path", - description: "Path to the OpenCode binary.", - providerSettingsForm: { - placeholder: "opencode", - clearWhenEmpty: "omit", - }, - }), - ), - serverUrl: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server URL", - description: "Leave blank to let T3 Code spawn the server when needed.", - providerSettingsForm: { - placeholder: "http://127.0.0.1:4096", - clearWhenEmpty: "omit", - }, - }), - ), - serverPassword: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server password", - description: "Stored in plain text on disk.", - providerSettingsForm: { - control: "password", - placeholder: "Optional", - clearWhenEmpty: "omit", - }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["binaryPath", "serverUrl", "serverPassword"], - }, -); -export type OpenCodeSettings = typeof OpenCodeSettings.Type; - /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index fa21d1f2efee..a733491676b7 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -75,6 +75,10 @@ "types": "./src/server/mcpToolPresentation.ts", "import": "./src/server/mcpToolPresentation.ts" }, + "./server/nativeProtocolLogging": { + "types": "./src/server/nativeProtocolLogging.ts", + "import": "./src/server/nativeProtocolLogging.ts" + }, "./server/notification": { "types": "./src/server/notification.ts", "import": "./src/server/notification.ts" @@ -127,6 +131,10 @@ "types": "./src/server/snapshotSettings.ts", "import": "./src/server/snapshotSettings.ts" }, + "./server/subagentProjection": { + "types": "./src/server/subagentProjection.ts", + "import": "./src/server/subagentProjection.ts" + }, "./server/textGeneration": { "types": "./src/server/textGeneration.ts", "import": "./src/server/textGeneration.ts" diff --git a/apps/server/src/provider/NativeProtocolLogging.ts b/packages/provider-core/src/server/nativeProtocolLogging.ts similarity index 100% rename from apps/server/src/provider/NativeProtocolLogging.ts rename to packages/provider-core/src/server/nativeProtocolLogging.ts diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts similarity index 100% rename from apps/server/src/orchestration-v2/SubagentProjection.ts rename to packages/provider-core/src/server/subagentProjection.ts diff --git a/packages/provider-opencode/package.json b/packages/provider-opencode/package.json new file mode 100644 index 000000000000..9aa836a69a6d --- /dev/null +++ b/packages/provider-opencode/package.json @@ -0,0 +1,68 @@ +{ + "name": "@t3tools/provider-opencode", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, + "./server/OpenCodeRuntime": { + "types": "./src/server/OpenCodeRuntime.ts", + "import": "./src/server/OpenCodeRuntime.ts" + }, + "./server/OpenCodeServerLedger": { + "types": "./src/server/OpenCodeServerLedger.ts", + "import": "./src/server/OpenCodeServerLedger.ts" + }, + "./server/v2/adapter": { + "types": "./src/server/v2/adapter.ts", + "import": "./src/server/v2/adapter.ts" + }, + "./server/v2/OpenCode2Client": { + "types": "./src/server/v2/OpenCode2Client.ts", + "import": "./src/server/v2/OpenCode2Client.ts" + }, + "./server/v2/OpenCode2Server": { + "types": "./src/server/v2/OpenCode2Server.ts", + "import": "./src/server/v2/OpenCode2Server.ts" + }, + "./server/v2/textGeneration": { + "types": "./src/server/v2/textGeneration.ts", + "import": "./src/server/v2/textGeneration.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@opencode-ai/sdk": "^1.3.15", + "@opencode/client": "2.0.23", + "@opencode/protocol": "2.0.23", + "@opencode/schema": "2.0.23", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-opencode/src/client.ts b/packages/provider-opencode/src/client.ts new file mode 100644 index 000000000000..4c5e2d165db0 --- /dev/null +++ b/packages/provider-opencode/src/client.ts @@ -0,0 +1,24 @@ +/** + * OpenCode's client definition: settings form, label, and glyph. + * Browser- and React Native-safe. + * + * @module provider-opencode/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { OpenCodeSettings } from "./settings.ts"; + +export const openCodeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("opencode"), + label: "OpenCode", + settingsSchema: OpenCodeSettings, + icon: { + viewBox: "0 0 32 40", + fill: { light: "#211E1E", dark: "#F1ECEC" }, + paths: [ + { d: "M24 32H8V16H24V32Z", fill: { light: "#CFCECD", dark: "#4B4646" } }, + { d: "M24 8H8V32H24V8ZM32 40H0V0H32V40Z" }, + ], + }, +}); diff --git a/packages/provider-opencode/src/server.ts b/packages/provider-opencode/src/server.ts new file mode 100644 index 000000000000..a9e0e2653cc0 --- /dev/null +++ b/packages/provider-opencode/src/server.ts @@ -0,0 +1,8 @@ +/** + * OpenCode's server entry: the driver the server registers, its adapter + * driver, and the runtime layer the driver needs from the server. + * + * @module provider-opencode/server + */ +export { OpenCodeDriver, type OpenCodeDriverEnv } from "./server/driver.ts"; +export { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts similarity index 89% rename from apps/server/src/provider/opencodeRuntime.cliParsers.test.ts rename to packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts index c66a2f36a207..499d7d5fee73 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts @@ -2,12 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { - parseAgentListCliOutput, - parseModelsCliOutput, - parseSkillsCliOutput, - toOpenCodeFileParts, -} from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; describe("parseModelsCliOutput", () => { it("parses a single model from a single provider", () => { @@ -27,7 +22,7 @@ describe("parseModelsCliOutput", () => { }), ].join("\n"); - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); NodeAssert.equal(result.providers.size, 1); NodeAssert.equal(result.connected.length, 1); NodeAssert.equal(result.connected[0], "anthropic"); @@ -55,7 +50,7 @@ describe("parseModelsCliOutput", () => { JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), ].join("\n"); - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); NodeAssert.equal(result.providers.size, 2); NodeAssert.equal(result.connected.length, 2); NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); @@ -64,7 +59,7 @@ describe("parseModelsCliOutput", () => { }); it("handles empty input", () => { - const result = parseModelsCliOutput(""); + const result = OpenCodeRuntime.parseModelsCliOutput(""); NodeAssert.equal(result.providers.size, 0); NodeAssert.equal(result.connected.length, 0); }); @@ -77,7 +72,7 @@ describe("parseModelsCliOutput", () => { JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), ].join("\n"); - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); NodeAssert.equal(result.providers.size, 1); const provider = result.providers.get("anthropic")!; NodeAssert.equal(Object.keys(provider.models).length, 1); @@ -90,7 +85,7 @@ describe("parseModelsCliOutput", () => { JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + "\r\n"; - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); NodeAssert.equal(result.providers.size, 1); NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); }); @@ -122,7 +117,7 @@ describe("parseModelsCliOutput", () => { }), ].join("\n"); - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); const model = result.providers.get("opencode")!.models["gpt-5.4"]!; NodeAssert.ok(model); NodeAssert.ok(model.capabilities); @@ -145,7 +140,7 @@ describe("parseModelsCliOutput", () => { }), ].join("\n"); - const result = parseModelsCliOutput(stdout); + const result = OpenCodeRuntime.parseModelsCliOutput(stdout); NodeAssert.equal(result.providers.size, 1); NodeAssert.deepEqual([...result.connected], ["openrouter"]); const provider = result.providers.get("openrouter")!; @@ -164,7 +159,7 @@ describe("parseAgentListCliOutput", () => { " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), ].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result.length, 1); NodeAssert.equal(result[0]!.name, "build"); NodeAssert.equal(result[0]!.mode, "primary"); @@ -181,7 +176,7 @@ describe("parseAgentListCliOutput", () => { " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), ].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result.length, 3); NodeAssert.equal(result[0]!.name, "build"); NodeAssert.equal(result[0]!.mode, "primary"); @@ -192,7 +187,7 @@ describe("parseAgentListCliOutput", () => { }); it("handles empty input", () => { - const result = parseAgentListCliOutput(""); + const result = OpenCodeRuntime.parseAgentListCliOutput(""); NodeAssert.equal(result.length, 0); }); @@ -204,7 +199,7 @@ describe("parseAgentListCliOutput", () => { " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), ].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result.length, 1); NodeAssert.equal(result[0]!.name, "explore"); }); @@ -221,7 +216,7 @@ describe("parseAgentListCliOutput", () => { ]; const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result.length, 1); NodeAssert.equal(result[0]!.permission.length, 3); NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); @@ -236,7 +231,7 @@ describe("parseAgentListCliOutput", () => { " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), ].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result.length, 2); NodeAssert.equal(result[0]!.name, "code reviewer"); NodeAssert.equal(result[0]!.mode, "subagent"); @@ -252,7 +247,7 @@ describe("parseAgentListCliOutput", () => { " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), ].join("\n"); - const result = parseAgentListCliOutput(stdout); + const result = OpenCodeRuntime.parseAgentListCliOutput(stdout); NodeAssert.equal(result[0]!.hidden, true); NodeAssert.equal(result[1]!.hidden, false); }); @@ -260,7 +255,7 @@ describe("parseAgentListCliOutput", () => { describe("parseSkillsCliOutput", () => { it("parses only skill metadata from the CLI JSON output", () => { - const result = parseSkillsCliOutput( + const result = OpenCodeRuntime.parseSkillsCliOutput( JSON.stringify([ { name: "review-pr", @@ -281,7 +276,7 @@ describe("parseSkillsCliOutput", () => { }); it("degrades malformed output to an empty skill list", () => { - NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); + NodeAssert.deepEqual(OpenCodeRuntime.parseSkillsCliOutput("not json"), []); }); }); @@ -295,7 +290,7 @@ describe("toOpenCodeFileParts", () => { }); it("sends supported images, text, and PDFs natively and skips what models reject", () => { - const parts = toOpenCodeFileParts({ + const parts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: [ attachment("application/pdf"), attachment("text/markdown"), @@ -320,7 +315,7 @@ describe("toOpenCodeFileParts", () => { }); it("keeps folded clipboard text on the lazy path fallback", () => { - const parts = toOpenCodeFileParts({ + const parts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: [ { ...attachment("text/plain"), diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts similarity index 99% rename from apps/server/src/provider/opencodeRuntime.environment.test.ts rename to packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts index 9b382347dcd6..fdd1ae3319bd 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.environment.test.ts @@ -15,7 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; import { FetchHttpClient, HttpClient } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; describe("resolveOpenCodeConfigContent", () => { diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts similarity index 99% rename from apps/server/src/provider/opencodeRuntime.inventory.test.ts rename to packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts index ad29f73c8d6d..29e302c134f1 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.inventory.test.ts @@ -15,7 +15,7 @@ import { HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; const layerTest = OpenCodeRuntime.layer.pipe( diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts similarity index 89% rename from apps/server/src/provider/opencodeRuntime.permissions.test.ts rename to packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts index a6ae1fbe0437..afa6da63f94c 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts @@ -3,15 +3,15 @@ import * as NodeAssert from "node:assert/strict"; import * as RegExpUtils from "effect/RegExp"; import { describe, it } from "vite-plus/test"; -import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; function actionFor( - runtimeMode: Parameters[0], + runtimeMode: Parameters[0], permission: string, target = "*", ) { // OpenCode uses the last matching rule. Its wildcards match directory separators. - return buildOpenCodePermissionRules(runtimeMode).findLast( + return OpenCodeRuntime.buildOpenCodePermissionRules(runtimeMode).findLast( (rule) => (rule.permission === "*" || rule.permission === permission) && new RegExp(`^${RegExpUtils.escape(rule.pattern).replaceAll("\\*", ".*")}$`, "s").test(target), @@ -69,7 +69,7 @@ describe("buildOpenCodePermissionRules", () => { }); it("allows everything only under full access", () => { - NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ + NodeAssert.deepEqual(OpenCodeRuntime.buildOpenCodePermissionRules("full-access"), [ { permission: "*", pattern: "*", action: "allow" }, { permission: "external_directory", pattern: "*", action: "allow" }, ]); @@ -84,6 +84,6 @@ describe("toOpenCodePermissionReply", () => { ["decline", "reject"], ["cancel", "reject"], ] as const)("maps %s to %s", (decision, reply) => { - NodeAssert.equal(toOpenCodePermissionReply(decision), reply); + NodeAssert.equal(OpenCodeRuntime.toOpenCodePermissionReply(decision), reply); }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts similarity index 99% rename from apps/server/src/provider/opencodeRuntime.ts rename to packages/provider-opencode/src/server/OpenCodeRuntime.ts index 979cacc75b14..935294575057 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { collectStreamAsString } from "@t3tools/provider-core/server/snapshotProbe"; import * as NetService from "@t3tools/shared/Net"; @@ -1103,7 +1103,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); export class OpenCodeRuntime extends Context.Service()( - "t3/provider/opencodeRuntime", + "@t3tools/provider-opencode/server/OpenCodeRuntime", ) {} export const layer = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( diff --git a/apps/server/src/provider/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts similarity index 99% rename from apps/server/src/provider/OpenCodeServerLedger.test.ts rename to packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 9f1b297b816d..a805572e8aac 100644 --- a/apps/server/src/provider/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -14,7 +14,7 @@ import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; const SERVE_ARGS = ["serve", "--hostname=127.0.0.1", "--port=4096"]; diff --git a/apps/server/src/provider/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts similarity index 95% rename from apps/server/src/provider/OpenCodeServerLedger.ts rename to packages/provider-opencode/src/server/OpenCodeServerLedger.ts index 8243e37f0481..0c721cf6ffd5 100644 --- a/apps/server/src/provider/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -8,8 +8,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { writeFileStringAtomically } from "../atomicWrite.ts"; -import * as ServerConfig from "../config.ts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; const ProcessIdentity = Schema.Struct({ pid: Schema.Int, startTime: Schema.String }); @@ -57,7 +56,7 @@ export class OpenCodeServerLedger extends Context.Service< readonly args: ReadonlyArray; }) => Effect.Effect>; } ->()("t3/provider/OpenCodeServerLedger") {} +>()("@t3tools/provider-opencode/server/OpenCodeServerLedger") {} const ENTRY_DIRECTORY = "opencode-servers"; const ENTRY_FILE = /^\d+\.json$/; @@ -321,16 +320,17 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { return { track, reapOrphans }; }); -export const layer = Layer.effect( - OpenCodeServerLedger, - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const ledger = yield* make({ stateDir: config.stateDir }); - // Reaping waits for orphans to exit, so it must not hold up startup. - yield* ledger.reapOrphans.pipe(Effect.forkScoped); - return OpenCodeServerLedger.of({ track: ledger.track }); - }), -); +/** Tracks servers under `stateDir`, the server's own state directory. */ +export const layer = (input: { readonly stateDir: string }) => + Layer.effect( + OpenCodeServerLedger, + Effect.gen(function* () { + const ledger = yield* make(input); + // Reaping waits for orphans to exit, so it must not hold up startup. + yield* ledger.reapOrphans.pipe(Effect.forkScoped); + return OpenCodeServerLedger.of({ track: ledger.track }); + }), + ); /** Records nothing. For tests that start OpenCode servers without a state directory. */ export const layerTest = Layer.succeed( diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts similarity index 99% rename from apps/server/src/provider/OpenCodeServerOwner.test.ts rename to packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts index e789e2bec719..f1f9881fdc29 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts @@ -7,7 +7,7 @@ import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; const unusedRuntimeMethod = () => diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts similarity index 98% rename from apps/server/src/provider/OpenCodeServerOwner.ts rename to packages/provider-opencode/src/server/OpenCodeServerOwner.ts index 7b8999213673..05d65dba377d 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; @@ -24,7 +24,7 @@ export class OpenCodeServerOwner extends Context.Service< use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, ) => Effect.Effect; } ->()("t3/provider/OpenCodeServerOwner") {} +>()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} /** Owns the lazy local OpenCode server shared by one provider instance. */ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/packages/provider-opencode/src/server/adapter.test.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts rename to packages/provider-opencode/src/server/adapter.test.ts index c52447fafaeb..9f80cdbead98 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -4,7 +4,6 @@ import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; import { CheckpointId, NodeId, - OpenCodeSettings, ProjectId, ProviderInstanceId, ProviderSessionId, @@ -17,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; @@ -31,9 +31,10 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as ServerConfig from "../../config.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { @@ -48,8 +49,8 @@ import { makeOpenCodeAdapterV2, OPENCODE_PROVIDER, reconcileOpenCodePromptAdmissionStatus, -} from "./OpenCodeAdapterV2.ts"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +} from "./adapter.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ @@ -105,10 +106,10 @@ function asyncEventStream() { const OPENCODE_TEST_SETTINGS = Schema.decodeUnknownSync(OpenCodeSettings)({}); function runtimePolicy( - runtimeMode: ProviderAdapterV2RuntimePolicy["runtimeMode"], - override: Partial = {}, -): ProviderAdapterV2RuntimePolicy { - return ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], + override: Partial = {}, +): ProviderAdapter.ProviderAdapterV2RuntimePolicy { + return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode, interactionMode: "default", cwd: null, @@ -163,12 +164,12 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct runtime: { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, }); const runtime = yield* adapter.openSession({ threadId, @@ -1495,12 +1496,12 @@ describe("OpenCodeAdapterV2", () => { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, }); const threadId = ThreadId.make("thread-opencode-admission-race"); const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); @@ -1790,12 +1791,12 @@ describe("OpenCodeAdapterV2", () => { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntimeShape, + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, idAllocator, - serverConfig: { - cwd: "/workspace", - attachmentsDir: "/tmp/attachments", - } as ServerConfig.ServerConfig["Service"], + host: { + paths: { cwd: "/workspace" }, + resolveAttachmentPath: () => null, + } as unknown as ProviderHost.ProviderHostShape, }); const threadId = ThreadId.make("thread-opencode-initial-stop"); const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); @@ -2387,7 +2388,7 @@ describe("OpenCodeAdapterV2", () => { Effect.gen(function* () { const idAllocator = yield* IdAllocator.IdAllocatorV2; const records: Array = []; - const nativeEventLogger: EventNdjsonLogger = { + const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { filePath: "/tmp/provider-native.ndjson", write: (event) => Effect.sync(() => void records.push(event)), close: () => Effect.void, @@ -2419,7 +2420,7 @@ describe("OpenCodeAdapterV2", () => { it.effect("adopts the handed-over provider thread identity on session create", () => Effect.gen(function* () { const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; let createCount = 0; const createInputs: Array = []; const fakeClient = { @@ -2447,7 +2448,7 @@ describe("OpenCodeAdapterV2", () => { }, } as unknown as OpencodeClient; const unused = (operation: string) => () => Effect.die(`${operation} is not used`); - const runtime: OpenCodeRuntimeShape = { + const runtime: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: unused("startOpenCodeServerProcess"), connectToOpenCodeServer: () => Effect.succeed({ @@ -2472,7 +2473,7 @@ describe("OpenCodeAdapterV2", () => { environment: {}, runtime, idAllocator, - serverConfig, + host, }); const session = yield* adapter.openSession({ threadId, @@ -2527,9 +2528,7 @@ describe("OpenCodeAdapterV2", () => { Effect.provide( Layer.mergeAll( IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-opencode-v2-adapter-", - }).pipe(Layer.provide(NodeServices.layer)), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), ), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/packages/provider-opencode/src/server/adapter.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts rename to packages/provider-opencode/src/server/adapter.ts index c746bec2180e..0fa85f3be6d4 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -15,7 +15,6 @@ import { causeErrorTag } from "@t3tools/shared/observability"; import { defaultInstanceIdForDriver, type ModelSelection, - type OpenCodeSettings, type OrchestrationV2AppThread, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, @@ -29,7 +28,6 @@ import { type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, type OrchestrationV2TurnItem, - OpenCodeSettings as OpenCodeSettingsSchema, type PlanId, ProviderDriverKind, type ProviderInstanceId, @@ -38,6 +36,7 @@ import { type RuntimeRequestId, type ThreadId, } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -54,20 +53,18 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, -} from "../../provider/NativeProtocolLogging.ts"; +} from "@t3tools/provider-core/server/nativeProtocolLogging"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; @@ -78,15 +75,18 @@ import { type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, } from "@t3tools/provider-core/server/adapterDriver"; -import { makeSubagentChildThread, subagentThreadTitle } from "../SubagentProjection.ts"; -import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; +import { + makeSubagentChildThread, + subagentThreadTitle, +} from "@t3tools/provider-core/server/subagentProjection"; +import { openCodeToolTurnItem } from "./toolItems.ts"; -export { openCodeToolProjectionKind } from "./OpenCodeToolItems.ts"; +export { openCodeToolProjectionKind } from "./toolItems.ts"; export const OPENCODE_PROVIDER = ProviderDriverKind.make("opencode"); export const OPENCODE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE_PROVIDER); export const OPENCODE_SDK_PROTOCOL = "opencode-sdk.sse" as const; -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettingsSchema)({}); +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({}); let openCodeMessageIdEpochMillis = -1; let openCodeMessageIdCounter = 0; @@ -440,8 +440,8 @@ export interface OpenCodeAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; - readonly nativeEventLogger?: EventNdjsonLogger; + readonly host: ProviderHost.ProviderHostShape; + readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; } export interface OpenCodeProtocolLogEvent { @@ -461,7 +461,7 @@ function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { } export function makeOpenCodeProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: ProviderSessionId; @@ -947,7 +947,7 @@ function unwrapData(operation: string, result: { readonly data?: A }): NonNul export function makeOpenCodeAdapterV2( options: OpenCodeAdapterV2Options, ): ProviderAdapter.ProviderAdapterV2Shape { - const { idAllocator, runtime, serverConfig } = options; + const { idAllocator, runtime, host } = options; return ProviderAdapter.ProviderAdapterV2.of({ instanceId: options.instanceId, @@ -957,7 +957,7 @@ export function makeOpenCodeAdapterV2( openSession: Effect.fn("OpenCodeAdapterV2.openSession")( function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { const scope = yield* Effect.scope; - const cwd = input.runtimePolicy.cwd ?? serverConfig.cwd; + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; const connection = yield* runtime.connectToOpenCodeServer({ binaryPath: options.settings.binaryPath, directory: cwd, @@ -2853,13 +2853,11 @@ export function makeOpenCodeAdapterV2( const text = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }).trim(); const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }); if (text.length === 0 && files.length === 0) { throw protocolError("OpenCode turns require text or at least one valid attachment"); @@ -3357,19 +3355,11 @@ export function makeOpenCodeAdapterV2( const text = providerMessageTextWithAttachmentPaths({ text: steerInput.message.text, attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), + resolveAttachmentPath: host.resolveAttachmentPath, }).trim(); const files = OpenCodeRuntime.toOpenCodeFileParts({ attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), + resolveAttachmentPath: host.resolveAttachmentPath, }); if (text.length === 0 && files.length === 0) { return yield* protocolError("OpenCode steering requires text or an attachment"); @@ -3740,14 +3730,14 @@ export type OpenCodeAdapterV2DriverEnv = | OpenCodeRuntime.OpenCodeRuntime | IdAllocator.IdAllocatorV2 | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< OpenCodeSettings, OpenCodeAdapterV2DriverEnv > = { driverKind: OPENCODE_PROVIDER, - configSchema: OpenCodeSettingsSchema, + configSchema: OpenCodeSettings, defaultConfig: (): OpenCodeSettings => DEFAULT_OPENCODE_SETTINGS, create: Effect.fn("OpenCodeAdapterV2Driver.create")( function* (input: ProviderAdapterDriverCreateInput) { @@ -3755,14 +3745,14 @@ export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; return makeOpenCodeAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), runtime: openCodeRuntime, idAllocator, - serverConfig, + host, ...(providerEventLoggers.native === undefined ? {} : { nativeEventLogger: providerEventLoggers.native }), @@ -3791,14 +3781,14 @@ const layer: Layer.Layer = []; const reachedServer = (operation: string) => @@ -48,18 +46,15 @@ const openCode2Runtime = { connectToOpenCodeServer: () => reachedServer("connect"), } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; -const layerDeps = Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-" }), +const layer = Layer.mergeAll( IdAllocator.layer, - ServerSettings.layerTest(), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + layerTestProviderHost(), Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(NodeServices.layer)); -const layer = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); const create = (config: Partial, http: HttpClient.HttpClient) => OpenCodeDriver.create({ @@ -213,18 +208,15 @@ const changingRuntime = { startOpenCodeServerProcess: () => reachedServer("start"), connectToOpenCodeServer: () => reachedServer("connect"), } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; -const layerUpdateDeps = Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode-driver-update-" }), +const layerUpdate = Layer.mergeAll( IdAllocator.layer, - ServerSettings.layerTest(), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + layerTestProviderHost(), Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), ).pipe(Layer.provideMerge(NodeServices.layer)); -const layerUpdate = ProviderHostLive.layer.pipe(Layer.provideMerge(layerUpdateDeps)); it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { it.effect("never runs the binary for a disabled instance's update check", () => diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/packages/provider-opencode/src/server/driver.ts similarity index 92% rename from apps/server/src/provider/Drivers/OpenCodeDriver.ts rename to packages/provider-opencode/src/server/driver.ts index 83347d86a87a..9c37ab2abd48 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -12,7 +12,8 @@ * * @module provider/Drivers/OpenCodeDriver */ -import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -24,16 +25,15 @@ import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import * as OpenCode2TextGeneration from "../../textGeneration/OpenCode2TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import * as OpenCodeAdapterV2 from "../../orchestration-v2/Adapters/OpenCodeAdapterV2.ts"; -import * as OpenCode2AdapterV2 from "../../orchestration-v2/Adapters/OpenCode2AdapterV2.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; -import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; +import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; +import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCodeAdapterV2 from "./adapter.ts"; +import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; import { checkOpenCodeProviderStatus, loadOpenCode2Workspace, @@ -43,17 +43,17 @@ import { openCode2SkillsToServerProviderSkills, openCodeSkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, -} from "../OpenCodeProvider.ts"; +} from "./status.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, probeOpenCodeRuntime, type ProbedOpenCode, -} from "../opencodeVersionProbe.ts"; -import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; -import * as OpenCode2Client from "../opencode2/OpenCode2Client.ts"; -import * as OpenCode2Server from "../opencode2/OpenCode2Server.ts"; +} from "./versionProbe.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; +import * as OpenCode2Server from "./v2/OpenCode2Server.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -123,9 +123,9 @@ function byOpenCodeRuntime( */ function selectOpenCodeRuntimeAdapter(input: { readonly probe: OpenCodeRuntimeProbe; - readonly v1: ProviderAdapterV2Shape; - readonly v2: ProviderAdapterV2Shape; -}): ProviderAdapterV2Shape { + readonly v1: ProviderAdapter.ProviderAdapterV2Shape; + readonly v2: ProviderAdapter.ProviderAdapterV2Shape; +}): ProviderAdapter.ProviderAdapterV2Shape { const pick = (probed: Effect.Effect) => byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) }); const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined)); @@ -143,9 +143,9 @@ function selectOpenCodeRuntimeAdapter(input: { /** Text generation runs on the server the instance's probe detected, each in its own protocol. */ function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, - v1: TextGeneration["Service"], - v2: TextGeneration["Service"], -): TextGeneration["Service"] { + v1: ProviderTextGeneration, + v2: ProviderTextGeneration, +): ProviderTextGeneration { return { generateCommitMessage: (input) => byOpenCodeRuntime(probe.get, { @@ -172,14 +172,13 @@ function selectOpenCodeRuntimeTextGeneration( export type OpenCodeDriverEnv = | OpenCodeAdapterV2.OpenCodeAdapterV2DriverEnv - | ProviderHost + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime - | Path.Path - | ServerConfig.ServerConfig; + | Path.Path; export const OpenCodeDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -195,10 +194,9 @@ export const OpenCodeDriver: ProviderDriver const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const serverConfig = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -279,7 +277,7 @@ export const OpenCodeDriver: ProviderDriver binaryPath: effectiveConfig.binaryPath, serverUrl: effectiveConfig.serverUrl, serverPassword: effectiveConfig.serverPassword, - directory: serverConfig.cwd, + directory: host.paths.cwd, environment: processEnv, }).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), @@ -299,7 +297,7 @@ export const OpenCodeDriver: ProviderDriver }); const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( openCode2Server.withConnection((connection) => - connection.client.model.list({ location: { directory: serverConfig.cwd } }).pipe( + connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( Effect.map((models) => models.data), Effect.mapError( (cause) => @@ -350,7 +348,7 @@ export const OpenCodeDriver: ProviderDriver ); const serverOwner = yield* OpenCodeServerOwner.make({ binaryPath: effectiveConfig.binaryPath, - directory: serverConfig.cwd, + directory: host.paths.cwd, ...(effectiveConfig.serverPassword ? { serverPassword: effectiveConfig.serverPassword } : {}), @@ -370,7 +368,7 @@ export const OpenCodeDriver: ProviderDriver { provider: checkOpenCodeProviderStatus( effectiveConfig, - serverConfig.cwd, + host.paths.cwd, runtimeProbe.refresh, loadOpenCode2Models, ), diff --git a/apps/server/src/provider/testFixtures/opencodeProbeResponses.ts b/packages/provider-opencode/src/server/probeResponses.fixture.ts similarity index 100% rename from apps/server/src/provider/testFixtures/opencodeProbeResponses.ts rename to packages/provider-opencode/src/server/probeResponses.fixture.ts diff --git a/apps/server/src/provider/OpenCodeProvider.test.ts b/packages/provider-opencode/src/server/status.test.ts similarity index 98% rename from apps/server/src/provider/OpenCodeProvider.test.ts rename to packages/provider-opencode/src/server/status.test.ts index 1341e851a80c..f062330ad733 100644 --- a/apps/server/src/provider/OpenCodeProvider.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -12,9 +12,8 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { beforeEach } from "vite-plus/test"; -import { OpenCodeSettings } from "@t3tools/contracts"; -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import { OpenCodeSettings } from "../settings.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus, @@ -25,14 +24,14 @@ import { openCode2CommandsToServerProviderSlashCommands, openCode2SkillsToServerProviderSkills, openCodeCommandsToServerProviderSlashCommands, -} from "./OpenCodeProvider.ts"; -import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; -import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts"; +} from "./status.ts"; +import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { probeOpenCodeRuntime } from "./versionProbe.ts"; import { OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES, replayOpenCodeServer, -} from "./testFixtures/opencodeProbeResponses.ts"; +} from "./probeResponses.fixture.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; @@ -315,7 +314,6 @@ it("keeps native and MCP commands while preserving compaction and separate skill }); const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/OpenCodeProvider.ts b/packages/provider-opencode/src/server/status.ts similarity index 99% rename from apps/server/src/provider/OpenCodeProvider.ts rename to packages/provider-opencode/src/server/status.ts index 0b638c47821c..f89298af01ae 100644 --- a/apps/server/src/provider/OpenCodeProvider.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -1,10 +1,10 @@ import { type ModelCapabilities, - type OpenCodeSettings, type ServerProviderModel, type ServerProviderSkill, type ServerProviderSlashCommand, } from "@t3tools/contracts"; +import type { OpenCodeSettings } from "../settings.ts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; @@ -20,8 +20,8 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "@t3tools/provider-core/server/snapshotProbe"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import type { ProbedOpenCode } from "./versionProbe.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts similarity index 96% rename from apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts rename to packages/provider-opencode/src/server/textGeneration.test.ts index f5d2aa1d2bbd..db93d27d2747 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -1,4 +1,5 @@ -import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { OpenCodeSettings } from "../settings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -9,11 +10,11 @@ import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; import { beforeEach, expect } from "vite-plus/test"; -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; -import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; -import * as TextGeneration from "./TextGeneration.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; +import * as OpenCodeTextGeneration from "./textGeneration.ts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; const runtimeMock = { state: { @@ -166,11 +167,7 @@ const layerOpenCodeTextGenerationTest = Layer.succeed( OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-test-", - }), - ), + Layer.provideMerge(layerTestProviderHost()), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), ); @@ -179,11 +176,7 @@ const layerOpenCodeTextGenerationExistingServerTest = Layer.succeed( OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-existing-server-test-", - }), - ), + Layer.provideMerge(layerTestProviderHost()), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), ); @@ -207,7 +200,7 @@ const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCod function withOpenCodeTextGeneration( settings: OpenCodeSettings, - effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + effectFn: (textGeneration: ProviderTextGeneration) => Effect.Effect, environment?: NodeJS.ProcessEnv, ) { return Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts similarity index 95% rename from apps/server/src/textGeneration/OpenCodeTextGeneration.ts rename to packages/provider-opencode/src/server/textGeneration.ts index 5ffcf4566185..8a48eaec7b41 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -1,14 +1,14 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { NonNegativeInt, TextGenerationError, type OpenCodeSettings } from "@t3tools/contracts"; +import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts"; +import type { OpenCodeSettings } from "../settings.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import * as ServerConfig from "../config.ts"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", @@ -152,7 +152,7 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, ) { - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; @@ -169,8 +169,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments?.filter((attachment) => attachment.type === "image"), - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }); const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts b/packages/provider-opencode/src/server/toolItems.ts similarity index 100% rename from apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts rename to packages/provider-opencode/src/server/toolItems.ts diff --git a/apps/server/src/provider/openCodeUsageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts similarity index 100% rename from apps/server/src/provider/openCodeUsageLimits.ts rename to packages/provider-opencode/src/server/usageLimits.ts diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts similarity index 100% rename from apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts similarity index 100% rename from apps/server/src/provider/opencode2/OpenCode2Client.test.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts diff --git a/apps/server/src/provider/opencode2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts similarity index 99% rename from apps/server/src/provider/opencode2/OpenCode2Client.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 231a5da3ae2b..3b9141e6e718 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -90,7 +90,7 @@ export class OpenCode2Client extends Context.Service< readonly password: string | Redacted.Redacted; }) => Effect.Effect; } ->()("t3/provider/opencode2/OpenCode2Client") {} +>()("@t3tools/provider-opencode/server/v2/OpenCode2Client") {} /** * OpenCode writes a `: heartbeat` comment every 10 to 15 seconds, so this long diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts similarity index 99% rename from apps/server/src/provider/opencode2/OpenCode2Server.live.test.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts index 93f511b4e843..81a38f9ae22b 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Server.live.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts @@ -23,7 +23,7 @@ import { FetchHttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { describe } from "vite-plus/test"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; import * as OpenCode2Client from "./OpenCode2Client.ts"; import * as OpenCode2Server from "./OpenCode2Server.ts"; diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts similarity index 99% rename from apps/server/src/provider/opencode2/OpenCode2Server.test.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts index 2704599b873c..43e31ece86fa 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Server.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts @@ -15,7 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { describe } from "vite-plus/test"; -import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; import * as OpenCode2Client from "./OpenCode2Client.ts"; import * as OpenCode2Server from "./OpenCode2Server.ts"; diff --git a/apps/server/src/provider/opencode2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts similarity index 94% rename from apps/server/src/provider/opencode2/OpenCode2Server.ts rename to packages/provider-opencode/src/server/v2/OpenCode2Server.ts index 993905521146..80b3dda1a101 100644 --- a/apps/server/src/provider/opencode2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -15,7 +15,7 @@ import * as P from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as HttpClientError from "effect/http/HttpClientError"; -import { OpenCodeRuntimeError } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import * as OpenCode2Client from "./OpenCode2Client.ts"; @@ -33,9 +33,9 @@ export class OpenCode2Server extends Context.Service< /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ readonly withConnection: ( use: (connection: OpenCode2Connection) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; } ->()("t3/provider/opencode2/OpenCode2Server") {} +>()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {} /** * A fresh password for a spawned server. OpenCode 2 always requires one and @@ -106,7 +106,7 @@ export const verifyServer = (client: OpenCodeClient) => duration: INFO_TIMEOUT, orElse: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "server.info", detail: "Timed out waiting for the OpenCode server.", }), @@ -115,7 +115,7 @@ export const verifyServer = (client: OpenCodeClient) => Effect.catchTags({ UnauthorizedError: (cause) => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "server.info", detail: "The OpenCode server rejected the server password.", cause, @@ -123,9 +123,9 @@ export const verifyServer = (client: OpenCodeClient) => ), }), Effect.mapError((cause) => - OpenCodeRuntimeError.is(cause) + OpenCodeRuntime.OpenCodeRuntimeError.is(cause) ? cause - : new OpenCodeRuntimeError({ + : new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "server.info", detail: describeInfoFailure(cause), cause, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/packages/provider-opencode/src/server/v2/adapter.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts rename to packages/provider-opencode/src/server/v2/adapter.ts index 6bc194b811a4..97429ddfde85 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -70,14 +70,10 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { paginate, type OpenCode2StreamEvent } from "../../provider/opencode2/OpenCode2Client.ts"; -import * as OpenCode2Server from "../../provider/opencode2/OpenCode2Server.ts"; -import { - parseOpenCodeModelSlug, - type OpenCodeRuntimeError, -} from "../../provider/opencodeRuntime.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCode2Client from "./OpenCode2Client.ts"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { t3OrchestrationSystemPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; @@ -98,11 +94,11 @@ import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, -} from "../SubagentProjection.ts"; +} from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "./OpenCodeAdapterV2.ts"; -import { openCodeToolTurnItem } from "./OpenCodeToolItems.ts"; +import { OPENCODE_PROVIDER, openCodePermissionRequestKind } from "../adapter.ts"; +import { openCodeToolTurnItem } from "../toolItems.ts"; const OpenCode2ProviderCapabilities = { sessions: { @@ -307,7 +303,7 @@ interface SubagentCall { * the continuation turn T3 opens for it takes them. */ interface Wake { - readonly events: Array; + readonly events: Array; running: boolean; /** The background subagents whose end it answers, as its turn's notification names them. */ readonly reports: Array; @@ -767,7 +763,7 @@ const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error) * default while T3 records the requested model. */ const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { - const parsed = parseOpenCodeModelSlug(selection.model); + const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model); if (parsed === null) return undefined; const variant = getModelSelectionStringOptionValue(selection, "variant"); return Model.Ref.make({ @@ -834,7 +830,7 @@ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["s export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { const server = yield* OpenCode2Server.OpenCode2Server; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const driver = OPENCODE_PROVIDER; @@ -846,7 +842,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr * server that died is started again on the next borrow. */ const borrow = Effect.gen(function* () { - const lent = yield* Deferred.make(); + const lent = yield* Deferred.make< + OpenCode2Server.OpenCode2Connection, + OpenCodeRuntime.OpenCodeRuntimeError + >(); yield* server .withConnection((connection) => Deferred.succeed(lent, connection).pipe(Effect.andThen(Effect.never)), @@ -874,7 +873,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // the instance's threads in every directory. const contextWindows = new Map>(); /** A thread without a worktree runs where T3 does, as its session is created. */ - const directoryOf = (cwd: string | null | undefined) => cwd ?? serverConfig.cwd; + const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd; const windowOf = (cwd: string | null | undefined, model: string) => contextWindows.get(directoryOf(cwd))?.get(model); const now = yield* DateTime.now; @@ -883,7 +882,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr driver, providerInstanceId: instanceId, status: "ready", - cwd: input.runtimePolicy.cwd ?? serverConfig.cwd, + cwd: input.runtimePolicy.cwd ?? host.paths.cwd, model: input.modelSelection.model, capabilities: OpenCode2ProviderCapabilities, createdAt: now, @@ -2145,7 +2144,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - const sessionOfEvent = (event: OpenCode2StreamEvent) => + const sessionOfEvent = (event: OpenCode2Client.OpenCode2StreamEvent) => event.type === "unreadable.execution.ended" || event.type === "unreadable.execution.started" ? event.sessionID : "sessionID" in event.data && typeof event.data.sessionID === "string" @@ -2193,7 +2192,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const onTurnEvent = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, - event: OpenCode2StreamEvent, + event: OpenCode2Client.OpenCode2StreamEvent, ) { switch (event.type) { case "session.text.started": @@ -2444,7 +2443,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ? finishTurn(state, turn.heldEnd) : Effect.void; - const handleEvent = Effect.fnUntraced(function* (event: OpenCode2StreamEvent) { + const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) { const sessionId = sessionOfEvent(event); // `revert.clear` wakes the session into an empty execution of its own // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of @@ -2499,7 +2498,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr /** Handles one event for its session, as the turn running there sees it. */ const route = Effect.fnUntraced(function* ( - event: OpenCode2StreamEvent, + event: OpenCode2Client.OpenCode2StreamEvent, sessionId: string | undefined, ) { // The end of the run a timed-out Stop left behind; no turn is its own. @@ -2678,7 +2677,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const { before } = turn; if (promptId === undefined && before === undefined) return undefined; const start = before !== undefined ? before : promptId; - const read = yield* paginate( + const read = yield* OpenCode2Client.paginate( { sessionID: Session.ID.make(sessionId), order: "desc" as const, limit: 50 }, client.message.list, ).pipe( @@ -2856,7 +2855,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // The borrow in use when the session closes is returned with it, so a // spawned server can still reach its idle shutdown. yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); - const follow = (stream: Stream.Stream): Effect.Effect => + const follow = ( + stream: Stream.Stream, + ): Effect.Effect => stream.pipe( Stream.runForEach((event) => lock.withPermit(handleEvent(event))), Effect.exit, @@ -3143,7 +3144,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr * reports continuations answer. The list takes one type, so it reads all. */ const userMessages = (sessionId: string) => - paginate({ sessionID: Session.ID.make(sessionId), limit: 100 }, client.message.list).pipe( + OpenCode2Client.paginate( + { sessionID: Session.ID.make(sessionId), limit: 100 }, + client.message.list, + ).pipe( Stream.runCollect, Effect.map( (messages) => @@ -3160,7 +3164,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerThread: OrchestrationV2ProviderThread, sessionId: string, ) { - const history = yield* paginate( + const history = yield* OpenCode2Client.paginate( { sessionID: Session.ID.make(sessionId), order: "asc" as const, limit: 100 }, client.message.list, ).pipe(Stream.runCollect); @@ -3216,8 +3220,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }).trim(); const removeMcp = (mcp: { readonly name: string; readonly directory: string }) => @@ -3249,7 +3252,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, ) { const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId); - const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd; + const directory = turnInput.runtimePolicy.cwd ?? host.paths.cwd; const name = yield* mcpServerNameFor(turnInput.threadId); // An external server may not reach T3's MCP endpoint, as with 1.x. const wanted = @@ -3523,7 +3526,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (!sending()) return; return yield* client.session.compact({ sessionID, id }).pipe(Effect.asVoid); } - const location = { directory: turnInput.runtimePolicy.cwd ?? serverConfig.cwd }; + const location = { directory: turnInput.runtimePolicy.cwd ?? host.paths.cwd }; const command = bare ? commandOf(text) : undefined; if (command !== undefined) { const commands = yield* client.command.list({ location }).pipe( @@ -3603,7 +3606,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: malformedModel(threadInput.modelSelection.model), }); } - const directory = threadInput.runtimePolicy.cwd ?? serverConfig.cwd; + const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd; const policy = threadInput.runtimePolicy; // A new session runs OpenCode's default agent. const permissions = yield* rulesFor( @@ -3898,11 +3901,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr text: providerMessageTextWithAttachmentPaths({ text: steerInput.message.text, attachments: steerInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), + resolveAttachmentPath: host.resolveAttachmentPath, }).trim(), delivery: "steer", }) diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts similarity index 92% rename from apps/server/src/textGeneration/OpenCode2TextGeneration.ts rename to packages/provider-opencode/src/server/v2/textGeneration.ts index f6ceb7252a8e..4ac6b8e2ec81 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -14,11 +14,9 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as ServerConfig from "../config.ts"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; -import type { OpenCode2Connection } from "../provider/opencode2/OpenCode2Server.ts"; -import * as OpenCode2Server from "../provider/opencode2/OpenCode2Server.ts"; -import { parseOpenCodeModelSlug } from "../provider/opencodeRuntime.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const isTextGenerationError = Schema.is(TextGenerationError); @@ -40,13 +38,13 @@ type Outcome = | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; const runOnServer = ( - connection: OpenCode2Connection, + connection: OpenCode2Server.OpenCode2Connection, input: TextGenerationOperations.Request, - attachmentsDir: string, + resolveAttachmentPath: ProviderHost.ProviderHostShape["resolveAttachmentPath"], ) => Effect.gen(function* () { const { client } = connection; - const parsed = parseOpenCodeModelSlug(input.modelSelection.model); + const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); if (parsed === null) { return yield* new TextGenerationError({ operation: input.operation, @@ -146,7 +144,7 @@ const runOnServer = ( ); const images = (input.attachments ?? []).flatMap((attachment) => { if (attachment.type !== "image") return []; - const path = resolveAttachmentPath({ attachmentsDir, attachment }); + const path = resolveAttachmentPath(attachment); return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }]; }); yield* client.session.prompt({ @@ -186,10 +184,10 @@ const runOnServer = ( /** Text generation for an instance whose server is OpenCode 2. */ export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { const server = yield* OpenCode2Server.OpenCode2Server; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost; const run: TextGenerationOperations.Runner = (input) => server - .withConnection((connection) => runOnServer(connection, input, attachmentsDir)) + .withConnection((connection) => runOnServer(connection, input, resolveAttachmentPath)) .pipe( Effect.mapError((cause) => isTextGenerationError(cause) diff --git a/apps/server/src/provider/opencodeVersionProbe.test.ts b/packages/provider-opencode/src/server/versionProbe.test.ts similarity index 98% rename from apps/server/src/provider/opencodeVersionProbe.test.ts rename to packages/provider-opencode/src/server/versionProbe.test.ts index 75d3873e19a4..f0296025d3a8 100644 --- a/apps/server/src/provider/opencodeVersionProbe.test.ts +++ b/packages/provider-opencode/src/server/versionProbe.test.ts @@ -2,17 +2,17 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { classifyOpenCodeCliVersion, makeOpenCodeRuntimeProbe, probeOpenCodeRuntime, -} from "./opencodeVersionProbe.ts"; +} from "./versionProbe.ts"; import { OPENCODE_1_RESPONSES, OPENCODE_2_RESPONSES, replayOpenCodeServer, -} from "./testFixtures/opencodeProbeResponses.ts"; +} from "./probeResponses.fixture.ts"; const noBinary = { runOpenCodeCommand: () => Effect.die("A configured server must not run the local binary"), diff --git a/apps/server/src/provider/opencodeVersionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts similarity index 99% rename from apps/server/src/provider/opencodeVersionProbe.ts rename to packages/provider-opencode/src/server/versionProbe.ts index aca8ab2e8189..c1ac522f3424 100644 --- a/apps/server/src/provider/opencodeVersionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/http"; -import * as OpenCodeRuntime from "./opencodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; export interface ProbedOpenCode { diff --git a/packages/provider-opencode/src/settings.ts b/packages/provider-opencode/src/settings.ts new file mode 100644 index 000000000000..5881b75e00f9 --- /dev/null +++ b/packages/provider-opencode/src/settings.ts @@ -0,0 +1,66 @@ +/** + * OpenCode instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-opencode/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const OpenCodeSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): the binding is not yet stable + // enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("opencode").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the OpenCode binary.", + providerSettingsForm: { + placeholder: "opencode", + clearWhenEmpty: "omit", + }, + }), + ), + serverUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server URL", + description: "Leave blank to let T3 Code spawn the server when needed.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:4096", + clearWhenEmpty: "omit", + }, + }), + ), + serverPassword: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Server password", + description: "Stored in plain text on disk.", + providerSettingsForm: { + control: "password", + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "serverUrl", "serverPassword"], + }, +); +export type OpenCodeSettings = typeof OpenCodeSettings.Type; diff --git a/packages/provider-opencode/src/testing.ts b/packages/provider-opencode/src/testing.ts new file mode 100644 index 000000000000..5fa87cde4e50 --- /dev/null +++ b/packages/provider-opencode/src/testing.ts @@ -0,0 +1,13 @@ +/** + * OpenCode internals the server's replay testkits and live tests drive + * directly: the 2.x client, server, adapter, and text generation, plus the + * 1.x transcript identifiers. + * + * @module provider-opencode/testing + */ +export { + OPENCODE_DEFAULT_INSTANCE_ID, + OPENCODE_PROVIDER, + OPENCODE_SDK_PROTOCOL, +} from "./server/adapter.ts"; +export { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./server/v2/adapter.ts"; diff --git a/packages/provider-opencode/tsconfig.json b/packages/provider-opencode/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-opencode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index ec9277fc0cd4..c5bca3054290 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -462,6 +462,10 @@ "./mcpApp": { "types": "./src/mcpApp.ts", "import": "./src/mcpApp.ts" + }, + "./atomicWrite": { + "types": "./src/atomicWrite.ts", + "import": "./src/atomicWrite.ts" } }, "scripts": { diff --git a/apps/server/src/atomicWrite.test.ts b/packages/shared/src/atomicWrite.test.ts similarity index 100% rename from apps/server/src/atomicWrite.test.ts rename to packages/shared/src/atomicWrite.test.ts diff --git a/apps/server/src/atomicWrite.ts b/packages/shared/src/atomicWrite.ts similarity index 100% rename from apps/server/src/atomicWrite.ts rename to packages/shared/src/atomicWrite.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4502702d9c19..39a350f843f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -594,21 +597,15 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 - '@opencode/client': - specifier: 2.0.23 - version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) - '@opencode/protocol': - specifier: 2.0.23 - version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) - '@opencode/schema': - specifier: 2.0.23 - version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -742,6 +739,9 @@ importers: '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse + '@t3tools/provider-opencode': + specifier: workspace:* + version: link:../../packages/provider-opencode '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -1142,6 +1142,49 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-opencode: + dependencies: + '@opencode-ai/sdk': + specifier: ^1.3.15 + version: 1.15.13 + '@opencode/client': + specifier: 2.0.23 + version: 2.0.23(patch_hash=f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)) + '@opencode/protocol': + specifier: 2.0.23 + version: 2.0.23(patch_hash=17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685) + '@opencode/schema': + specifier: 2.0.23 + version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-pi: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 2f39d7a61e93..400fc17efab4 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -32,6 +32,7 @@ const workspaceFiles = [ "packages/provider-testing/package.json", "packages/provider-pi/package.json", "packages/provider-muse/package.json", + "packages/provider-opencode/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", From b5e4f1e0b0c2c06f183ab6a89072bec21c8334c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:30:57 -0700 Subject: [PATCH 046/377] refactor(provider-cursor): move Cursor into its own provider package (#17349) Co-authored-by: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/components/ProviderIcon.tsx | 11 -- apps/mobile/src/lib/providerClients.ts | 8 +- apps/server/package.json | 1 + .../Adapters/CursorAdapterV2.testkit.ts | 103 +----------------- .../orchestration-v2/CommandPolicy.test.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- .../ProviderSwitch.integration.test.ts | 2 +- .../src/provider/AntigravityProvider.test.ts | 4 +- .../Drivers/AntigravityDriver.test.ts | 6 +- .../src/provider/Drivers/GrokDriver.test.ts | 6 +- apps/server/src/provider/ProviderHostLive.ts | 30 +++++ .../provider/ProviderInstanceRegistry.test.ts | 4 +- ...viderOrchestrationAdapterInfrastructure.ts | 2 +- apps/server/src/provider/builtInDrivers.ts | 2 +- .../src/usage/cursorUsageReader.test.ts | 2 +- apps/server/src/usage/cursorUsageReader.ts | 2 +- apps/web/package.json | 1 + .../components/chat/ProviderInstanceIcon.tsx | 4 +- .../components/settings/providerDriverMeta.ts | 17 +-- docs/internals/adding-a-provider.md | 2 +- knip.jsonc | 4 +- package.json | 2 +- packages/contracts/src/settings.ts | 26 ----- .../provider-core/src/server/ProviderHost.ts | 17 +++ packages/provider-core/src/server/errors.ts | 13 +++ .../src/server/managedProvider.test.ts | 1 + packages/provider-cursor/package.json | 47 ++++++++ packages/provider-cursor/src/client.ts | 34 ++++++ packages/provider-cursor/src/server.ts | 10 ++ .../src/server}/CursorAgentSdk.test.ts | 4 +- .../src/server}/CursorAgentSdk.ts | 9 +- .../src/server}/CursorSdkCatalog.test.ts | 0 .../src/server}/CursorSdkCatalog.ts | 4 +- .../src/server/adapter.test.ts | 41 ++++--- .../provider-cursor/src/server/adapter.ts | 37 +++---- .../provider-cursor/src/server/auth.test.ts | 4 +- .../provider-cursor/src/server/auth.ts | 4 +- .../src/server/credentialStore.test.ts | 65 +++-------- .../src/server/credentialStore.ts | 4 +- .../provider-cursor/src/server/driver.test.ts | 32 ++---- .../provider-cursor/src/server/driver.ts | 46 +++----- .../src/server/keychainToken.test.ts | 5 +- .../src/server/keychainToken.ts | 0 .../provider-cursor/src/server/sdk.test.ts | 4 +- .../provider-cursor/src/server/sdk.ts | 0 .../provider-cursor/src/server/sdkModel.ts | 0 .../provider-cursor/src/server/skills.test.ts | 2 +- .../provider-cursor/src/server/skills.ts | 0 .../provider-cursor/src/server/status.test.ts | 6 +- .../provider-cursor/src/server/status.ts | 4 +- .../src/server/textGeneration.test.ts | 7 +- .../src/server/textGeneration.ts | 13 +-- .../src/server/transportFailure.test.ts | 2 +- .../src/server/transportFailure.ts | 0 .../src/server/usageLimits.test.ts | 2 +- .../provider-cursor/src/server/usageLimits.ts | 5 +- packages/provider-cursor/src/settings.ts | 35 ++++++ packages/provider-cursor/src/testing.ts | 13 +++ packages/provider-cursor/tsconfig.json | 7 ++ packages/provider-testing/src/host.ts | 13 +++ pnpm-lock.yaml | 49 +++++++++ scripts/lib/cursor-sdk-packaging.test.ts | 2 +- scripts/release-smoke.ts | 1 + 64 files changed, 429 insertions(+), 357 deletions(-) create mode 100644 packages/provider-cursor/package.json create mode 100644 packages/provider-cursor/src/client.ts create mode 100644 packages/provider-cursor/src/server.ts rename {apps/server/src/orchestration-v2/Adapters => packages/provider-cursor/src/server}/CursorAgentSdk.test.ts (98%) rename {apps/server/src/orchestration-v2/Adapters => packages/provider-cursor/src/server}/CursorAgentSdk.ts (98%) rename {apps/server/src/provider => packages/provider-cursor/src/server}/CursorSdkCatalog.test.ts (100%) rename {apps/server/src/provider => packages/provider-cursor/src/server}/CursorSdkCatalog.ts (95%) rename apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts => packages/provider-cursor/src/server/adapter.test.ts (95%) rename apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts => packages/provider-cursor/src/server/adapter.ts (98%) rename apps/server/src/provider/CursorAuth.test.ts => packages/provider-cursor/src/server/auth.test.ts (99%) rename apps/server/src/provider/CursorAuth.ts => packages/provider-cursor/src/server/auth.ts (99%) rename apps/server/src/provider/CursorCredentialStore.test.ts => packages/provider-cursor/src/server/credentialStore.test.ts (62%) rename apps/server/src/provider/CursorCredentialStore.ts => packages/provider-cursor/src/server/credentialStore.ts (94%) rename apps/server/src/provider/Drivers/CursorDriver.test.ts => packages/provider-cursor/src/server/driver.test.ts (84%) rename apps/server/src/provider/Drivers/CursorDriver.ts => packages/provider-cursor/src/server/driver.ts (86%) rename apps/server/src/provider/cursorKeychainToken.test.ts => packages/provider-cursor/src/server/keychainToken.test.ts (91%) rename apps/server/src/provider/cursorKeychainToken.ts => packages/provider-cursor/src/server/keychainToken.ts (100%) rename apps/server/src/provider/cursorSdk.test.ts => packages/provider-cursor/src/server/sdk.test.ts (97%) rename apps/server/src/provider/cursorSdk.ts => packages/provider-cursor/src/server/sdk.ts (100%) rename apps/server/src/provider/cursorSdkModel.ts => packages/provider-cursor/src/server/sdkModel.ts (100%) rename apps/server/src/provider/Drivers/CursorSkills.test.ts => packages/provider-cursor/src/server/skills.test.ts (99%) rename apps/server/src/provider/Drivers/CursorSkills.ts => packages/provider-cursor/src/server/skills.ts (100%) rename apps/server/src/provider/CursorProvider.test.ts => packages/provider-cursor/src/server/status.test.ts (97%) rename apps/server/src/provider/CursorProvider.ts => packages/provider-cursor/src/server/status.ts (99%) rename apps/server/src/textGeneration/CursorTextGeneration.test.ts => packages/provider-cursor/src/server/textGeneration.test.ts (98%) rename apps/server/src/textGeneration/CursorTextGeneration.ts => packages/provider-cursor/src/server/textGeneration.ts (95%) rename apps/server/src/provider/acp/CursorTransportFailure.test.ts => packages/provider-cursor/src/server/transportFailure.test.ts (97%) rename apps/server/src/provider/acp/CursorTransportFailure.ts => packages/provider-cursor/src/server/transportFailure.ts (100%) rename apps/server/src/provider/cursorUsageLimits.test.ts => packages/provider-cursor/src/server/usageLimits.test.ts (99%) rename apps/server/src/provider/cursorUsageLimits.ts => packages/provider-cursor/src/server/usageLimits.ts (97%) create mode 100644 packages/provider-cursor/src/settings.ts create mode 100644 packages/provider-cursor/src/testing.ts create mode 100644 packages/provider-cursor/tsconfig.json diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 02ba1b20ae85..2b73304bebd1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -70,6 +70,7 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index dda6e6f3b544..f2eb4e3c7f70 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -109,17 +109,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "cursor") { - return ( - - - - ); - } - const packageIcon = getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index 11f7e13a3a78..cb469a28706c 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,11 +1,17 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { cursorClient } from "@t3tools/provider-cursor/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); +const providerClients = makeProviderClientRegistry([ + cursorClient, + museClient, + openCodeClient, + piClient, +]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index 11f202120f85..de20a01de3b9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,7 @@ "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index a3a72f741138..3f2f6581f90d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -1,6 +1,4 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import type { InteractionUpdate, RunResult } from "@cursor/sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -15,23 +13,21 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import * as CursorAgentSdk from "./CursorAgentSdk.ts"; +import { CursorAdapterV2Driver } from "@t3tools/provider-cursor/server"; +import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import { CURSOR_DEFAULT_INSTANCE_ID, CURSOR_DRIVER_KIND, - CursorAdapterV2Driver, cursorSdkModelSelection, makeCursorAgentOptions, -} from "./CursorAdapterV2.ts"; +} from "@t3tools/provider-cursor/testing"; import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts"; @@ -513,91 +509,6 @@ function layerCursorAgentSdkReplay( ); } -function makeReplayServerConfig( - scenario: string, -): Effect.Effect< - ServerConfig.ServerConfig["Service"], - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectory({ - prefix: `t3-orchestration-v2-cursor-${scenario}-`, - }); - const stateDir = path.join(baseDir, "userdata"); - const logsDir = path.join(stateDir, "logs"); - const providerLogsDir = path.join(logsDir, "provider"); - const terminalLogsDir = path.join(logsDir, "terminals"); - const attachmentsDir = path.join(stateDir, "attachments"); - const environmentThemesDir = path.join(stateDir, "themes"); - const worktreesDir = path.join(baseDir, "worktrees"); - const providerStatusCacheDir = path.join(baseDir, "caches"); - for (const directory of [ - stateDir, - logsDir, - providerLogsDir, - terminalLogsDir, - attachmentsDir, - environmentThemesDir, - worktreesDir, - providerStatusCacheDir, - ]) { - yield* fs.makeDirectory(directory, { recursive: true }); - } - return { - logLevel: "Error", - traceMinLevel: "Info", - traceTimingEnabled: true, - traceBatchWindowMs: 200, - traceMaxBytes: 10 * 1024 * 1024, - traceMaxFiles: 10, - otelEnvironment: OtelEnvironment.none, - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpLogsUrl: undefined, - otlpTracesExport: DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - mode: "web", - port: 0, - host: undefined, - cwd: process.cwd(), - baseDir, - staticDir: undefined, - devUrl: undefined, - devAllowedOrigins: [], - noBrowser: false, - startupPresentation: "browser", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - desktopBootstrapToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - stateDir, - dbPath: path.join(stateDir, "state.sqlite"), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - settingsPath: path.join(stateDir, "settings.json"), - providerStatusCacheDir, - worktreesDir, - attachmentsDir, - browserArtifactsDir: path.join(stateDir, "browser-artifacts"), - environmentThemesDir, - logsDir, - serverLogPath: path.join(logsDir, "server.log"), - serverTracePath: path.join(logsDir, "server.trace.ndjson"), - providerLogsDir, - providerEventLogPath: path.join(providerLogsDir, "events.log"), - terminalLogsDir, - anonymousIdPath: path.join(stateDir, "anonymous-id"), - environmentIdPath: path.join(stateDir, "environment-id"), - serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), - secretsDir: path.join(stateDir, "secrets"), - }; - }); -} - export function layer( transcript: CursorAgentSdkReplayTranscript, options?: { @@ -605,10 +516,6 @@ export function layer( readonly assertCompleteOnFinalize?: boolean; }, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); // Skill discovery also scans user roots under HOME; an empty HOME keeps // replays from picking up the host's own skills. const layerHostEnvironment = Layer.effect( @@ -630,7 +537,7 @@ export function layer( Layer.provide( Layer.mergeAll( layerCursorAgentSdkReplay(transcript, options), - layerServerConfig, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), layerHostEnvironment, NodeServices.layer, IdAllocator.layer, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 256ecd45e75a..517229c222ea 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -12,7 +12,7 @@ import { import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; +import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; import * as CommandPolicy from "./CommandPolicy.ts"; diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 3e33d5b2de41..e48ceb60177c 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -12,7 +12,7 @@ import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/C import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv, -} from "./Adapters/CursorAdapterV2.ts"; +} from "@t3tools/provider-cursor/server"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; import { OpenCodeAdapterV2Driver, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index a0de3392631c..b71043aabc4e 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -43,7 +43,7 @@ import { canReuseCodexContextUsage, } from "../Adapters/CodexAdapterV2.ts"; import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; -import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts"; +import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts"; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 77a436aaf7be..69502047f73d 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -22,6 +22,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; import { buildAntigravityModelsFromSession, @@ -116,9 +117,10 @@ const layerTest = ProviderHostLive.layer.pipe( }), ), Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(ServerSecretStore.layer), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( - Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index d08cacb60dcd..33aa39f5598b 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -34,6 +34,7 @@ import * as ModelManifest from "../ModelManifest.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { AntigravityDriver } from "./AntigravityDriver.ts"; import * as ProviderHostLive from "../ProviderHostLive.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; const hostPlatform = HostProcessPlatform.defaultValue(); const windowsHost = hostPlatform === "win32"; @@ -264,7 +265,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(IdAllocator.layer), ); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(layerDeps), +); it.layer(layerTest)("AntigravityDriver", (it) => { it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts index f1737f71f391..e0da3203902c 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -17,6 +17,7 @@ import { GrokDriver } from "./GrokDriver.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderHostLive from "../ProviderHostLive.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-grok-driver-update-", @@ -42,7 +43,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { ), ), ); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(layerDeps), +); const noSpawner = ChildProcessSpawner.make(() => Effect.die("Disabled Grok must not spawn a process"), diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 965cb47e365f..372ac75765c6 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -5,13 +5,17 @@ * @module provider/ProviderHostLive */ import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; export const layer = Layer.effect( ProviderHost, @@ -19,6 +23,8 @@ export const layer = Layer.effect( const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; return ProviderHost.of({ paths: { cwd: config.cwd, @@ -34,6 +40,30 @@ export const layer = Layer.effect( shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), + credentials: (namespace, bindingId) => + ProviderCredentialStore.make(namespace, bindingId).pipe( + Effect.map((store) => ({ + binding: store.binding, + get: store.get.pipe( + Effect.mapError((cause) => new ProviderCredentialError({ operation: "get", cause })), + ), + set: (credentials: Uint8Array) => + store + .set(credentials) + .pipe( + Effect.mapError( + (cause) => new ProviderCredentialError({ operation: "set", cause }), + ), + ), + remove: store.remove.pipe( + Effect.mapError( + (cause) => new ProviderCredentialError({ operation: "remove", cause }), + ), + ), + })), + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(Crypto.Crypto, crypto), + ), }); }), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 5c1bbb165822..353c29fb90da 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -31,12 +31,12 @@ import { EnvironmentId, type ClaudeSettings, type CodexSettings, - type CursorSettings, type GrokSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import type { CursorSettings } from "@t3tools/provider-cursor/settings"; import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -53,7 +53,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 6640288c7ff4..2cbb70fbbdec 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -2,7 +2,7 @@ import * as Layer from "effect/Layer"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; +import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 8cfcc007e9c1..516e52d0e49b 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,7 +24,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegis import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts index 0c6783757748..4897f4604a9e 100644 --- a/apps/server/src/usage/cursorUsageReader.test.ts +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { CursorKeychainTimeoutError } from "../provider/cursorKeychainToken.ts"; +import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; describe("readCursorAccountUsage", () => { diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 86ba226a0529..95b87f1984bb 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -12,7 +12,7 @@ import type { UsageRecord } from "./usageTranscripts.ts"; import { CursorKeychainTimeoutError, readMacCursorAccessToken, -} from "../provider/cursorKeychainToken.ts"; +} from "@t3tools/provider-cursor/server"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/apps/web/package.json b/apps/web/package.json index 372c956ec04e..64012d9bdab3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 5337fbc9cf3f..927f78ceb4f2 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react"; import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons"; +import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons"; import { cn } from "~/lib/utils"; import { providerClients } from "../settings/providerDriverMeta"; @@ -17,7 +17,6 @@ import { const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, - [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; @@ -25,7 +24,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: "text-black dark:text-white", [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", - [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]", [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index ae70d22ec9d7..aca6ffbc724f 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,11 @@ import { AntigravitySettings, ClaudeSettings, CodexSettings, - CursorSettings, GrokSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { cursorClient } from "@t3tools/provider-cursor/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -24,20 +24,7 @@ export const providerClients = makeProviderClientRegistry([ label: "Claude", settingsSchema: ClaudeSettings, }, - { - driverKind: ProviderDriverKind.make("cursor"), - label: "Cursor", - settingsSchema: CursorSettings, - environmentFields: [ - { - name: "CURSOR_API_KEY", - label: "Cursor API key", - description: "Optional. Overrides browser sign-in for this provider.", - placeholder: "Paste API key", - sensitive: true, - }, - ], - }, + cursorClient, { driverKind: ProviderDriverKind.make("grok"), label: "Grok", diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 3c6fa7f00649..b442053c8001 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -115,7 +115,7 @@ A provider lives in its own `packages/provider-` package, with Settings live in `providerInstances`; new providers are off by default. - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, - attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. + attachments, stored credentials), never through `apps/server`. Tests use `@t3tools/provider-testing`. - **Server registration:** the driver's entry in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts). diff --git a/knip.jsonc b/knip.jsonc index 24d0f85db881..c3fed7f19200 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -36,7 +36,9 @@ ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. - "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web"], + // Native and disk-loaded provider packages stay external to the bundle; the + // CLI archive and desktop sidecar stage them from this manifest. + "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web", "@napi-rs/keyring"], }, "apps/desktop": { // Electron loads these bundles by filename rather than importing them. diff --git a/package.json b/package.json index e261ffedfb35..545a4247c098 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a302412f179e..c43df190af3f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema( ); export type ClaudeSettings = typeof ClaudeSettings.Type; -export const CursorSettings = makeProviderSettingsSchema( - { - // Off by default like Grok and OpenCode. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - // Keep V1's CLI configuration when V2 rewrites the shared settings file. - // V2's Cursor SDK does not use these fields. - binaryPath: Schema.optionalKey(TrimmedString).pipe( - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - apiEndpoint: Schema.optionalKey(TrimmedString).pipe( - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: [], - }, -); -export type CursorSettings = typeof CursorSettings.Type; - export const GrokSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and OpenCode): the binding is not yet diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index f0a695da43d4..318037cdcb3b 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -17,9 +17,12 @@ import type { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; +import type { ProviderCredentialError } from "./errors.ts"; + export interface ProviderHostPaths { /** Directory the server was started in; the fallback cwd for sessions without a project. */ readonly cwd: string; @@ -31,6 +34,15 @@ export interface ProviderHostPaths { readonly providerStatusCacheDir: string; } +/** One opaque credential blob in the server's secret store. Only the provider decodes it. */ +export interface ProviderCredentials { + /** Instances whose bindings are equal share these credentials. */ + readonly binding: { readonly owner: "t3"; readonly key: string }; + readonly get: Effect.Effect, ProviderCredentialError>; + readonly set: (credentials: Uint8Array) => Effect.Effect; + readonly remove: Effect.Effect; +} + export interface ProviderHostShape { readonly paths: ProviderHostPaths; readonly settings: { @@ -47,6 +59,11 @@ export interface ProviderHostShape { * resolve inside the attachments directory. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; + /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ + readonly credentials: ( + namespace: string, + bindingId: string, + ) => Effect.Effect; } export class ProviderHost extends Context.Service()( diff --git a/packages/provider-core/src/server/errors.ts b/packages/provider-core/src/server/errors.ts index eda2eaa686ec..46ca6e6541fe 100644 --- a/packages/provider-core/src/server/errors.ts +++ b/packages/provider-core/src/server/errors.ts @@ -19,3 +19,16 @@ export class ProviderDriverError extends Schema.TaggedError return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; } } + +/** Reading or writing a provider's stored credentials failed. */ +export class ProviderCredentialError extends Schema.TaggedError()( + "ProviderCredentialError", + { + operation: Schema.Literals(["get", "set", "remove"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not ${this.operation} stored provider credentials.`; + } +} diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index a4d7dc752f1c..6d9e3827531b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -117,6 +117,7 @@ function layerProviderHost(input: { }, shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), resolveAttachmentPath: () => null, + credentials: () => Effect.die("unused"), }), ); } diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json new file mode 100644 index 000000000000..4a50b9647332 --- /dev/null +++ b/packages/provider-cursor/package.json @@ -0,0 +1,47 @@ +{ + "name": "@t3tools/provider-cursor", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, + "./server/CursorAgentSdk": { + "types": "./src/server/CursorAgentSdk.ts", + "import": "./src/server/CursorAgentSdk.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@cursor/sdk": "1.0.35", + "@napi-rs/keyring": "^1.3.0", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-cursor/src/client.ts b/packages/provider-cursor/src/client.ts new file mode 100644 index 000000000000..c76378ab1739 --- /dev/null +++ b/packages/provider-cursor/src/client.ts @@ -0,0 +1,34 @@ +/** + * Cursor's client definition: settings form, label, sign-in environment + * field, and glyph. Browser- and React Native-safe. + * + * @module provider-cursor/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { CursorSettings } from "./settings.ts"; + +export const cursorClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("cursor"), + label: "Cursor", + settingsSchema: CursorSettings, + environmentFields: [ + { + name: "CURSOR_API_KEY", + label: "Cursor API key", + description: "Optional. Overrides browser sign-in for this provider.", + placeholder: "Paste API key", + sensitive: true, + }, + ], + icon: { + viewBox: "0 0 466.73 532.09", + fill: { light: "#26251E", dark: "#EDECEC" }, + paths: [ + { + d: "M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z", + }, + ], + }, +}); diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts new file mode 100644 index 000000000000..471737aa58fd --- /dev/null +++ b/packages/provider-cursor/src/server.ts @@ -0,0 +1,10 @@ +/** + * Cursor's server entry: the driver the server registers, its adapter + * driver, the SDK runner layer the server provides once, and the keychain + * token reader the usage scanner shares. + * + * @module provider-cursor/server + */ +export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; +export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; +export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts rename to packages/provider-cursor/src/server/CursorAgentSdk.test.ts index 5754ec28706c..6a4e209bf192 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; const cursorSdkMock = vi.hoisted(() => { @@ -92,7 +92,7 @@ const cursorSdkMock = vi.hoisted(() => { }; }); -vi.mock("../../provider/cursorSdk.ts", () => ({ +vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create, resume: cursorSdkMock.resume, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts rename to packages/provider-cursor/src/server/CursorAgentSdk.ts index 036a93a0e557..30d6090fab4e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -16,9 +16,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import { Agent, createAgentPlatform } from "./sdk.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); @@ -77,7 +76,7 @@ export interface CursorAgentSdkRunnerShape { export class CursorAgentSdkRunner extends Context.Service< CursorAgentSdkRunner, CursorAgentSdkRunnerShape ->()("t3/orchestration-v2/Adapters/CursorAgentSdk/CursorAgentSdkRunner") {} +>()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} export interface CursorAgentSdkLoggedAgentOptions { readonly model?: AgentOptions["model"]; @@ -276,7 +275,7 @@ export function loggedCursorSendOptions( } function makeCursorAgentSdkProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; }): CursorAgentSdkProtocolLogger | undefined { diff --git a/apps/server/src/provider/CursorSdkCatalog.test.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.test.ts similarity index 100% rename from apps/server/src/provider/CursorSdkCatalog.test.ts rename to packages/provider-cursor/src/server/CursorSdkCatalog.test.ts diff --git a/apps/server/src/provider/CursorSdkCatalog.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.ts similarity index 95% rename from apps/server/src/provider/CursorSdkCatalog.ts rename to packages/provider-cursor/src/server/CursorSdkCatalog.ts index e866955dd55f..1a0f6b649496 100644 --- a/apps/server/src/provider/CursorSdkCatalog.ts +++ b/packages/provider-cursor/src/server/CursorSdkCatalog.ts @@ -7,7 +7,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts"; +import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts"; export interface CursorSdkCatalogSnapshot { readonly user: SDKUser; @@ -33,7 +33,7 @@ export interface CursorSdkCatalogShape { } export class CursorSdkCatalog extends Context.Service()( - "t3/provider/CursorSdkCatalog", + "@t3tools/provider-cursor/server/CursorSdkCatalog", ) {} function isAuthenticationFailure(cause: unknown): boolean { diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/packages/provider-cursor/src/server/adapter.test.ts similarity index 95% rename from apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts rename to packages/provider-cursor/src/server/adapter.test.ts index cf37340d0e18..730cbb7649f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import type { InteractionUpdate } from "@cursor/sdk"; import { - CursorSettings, EnvironmentId, MessageId, NodeId, @@ -13,6 +12,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -22,10 +22,11 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { cursorMcpServers, cursorRuntimeAgentPolicy, @@ -33,8 +34,8 @@ import { makeCursorAgentOptions, makeCursorAdapterV2, nestedToolCallFromEnvelope, -} from "./CursorAdapterV2.ts"; -import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAgentSdk.ts"; +} from "./adapter.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; const decodeCursorSettings = Schema.decodeEffect(CursorSettings); @@ -57,7 +58,7 @@ describe("CursorAdapterV2", () => { const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-lifecycle-thread"); const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: workspace, @@ -69,10 +70,8 @@ describe("CursorAdapterV2", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide( - ServerConfig.layerTest(workspace, { prefix: "cursor-v2-lifecycle-config-" }), - ), + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), ), runner: { assertComplete: Effect.void, @@ -219,7 +218,7 @@ describe("CursorAdapterV2", () => { const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-transport-error-thread"); const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: workspace, @@ -231,8 +230,8 @@ describe("CursorAdapterV2", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-error-config-" })), + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), ), runner: { assertComplete: Effect.void, @@ -367,7 +366,7 @@ describe("CursorAdapterV2", () => { const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-search-thread"); const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: workspace, @@ -572,8 +571,8 @@ describe("CursorAdapterV2", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-search-config-" })), + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), ), runner: { assertComplete: Effect.void, @@ -908,7 +907,7 @@ describe("CursorAdapterV2", () => { }); assert.deepEqual(options.mcpServers, cursorMcpServers(threadId)); - const logged = JSON.stringify(loggedCursorAgentOptions(options)); + const logged = JSON.stringify(CursorAgentSdk.loggedCursorAgentOptions(options)); assert.notInclude(logged, "secret-cursor-api-key"); assert.notInclude(logged, "secret-cursor-mcp-token"); } finally { @@ -917,9 +916,9 @@ describe("CursorAdapterV2", () => { }); it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { - assert.isTrue(isCursorCancellationError({ name: "AbortError" })); + assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" })); assert.isTrue( - isCursorCancellationError({ + CursorAgentSdk.isCursorCancellationError({ name: "ConnectError", cause: { name: "ConnectError", @@ -927,8 +926,8 @@ describe("CursorAdapterV2", () => { }, }), ); - assert.isFalse(isCursorCancellationError(new Error("request failed"))); - assert.isFalse(isCursorCancellationError(null)); + assert.isFalse(CursorAgentSdk.isCursorCancellationError(new Error("request failed"))); + assert.isFalse(CursorAgentSdk.isCursorCancellationError(null)); }); it("preserves failed nested read calls when Cursor omits their path", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/packages/provider-cursor/src/server/adapter.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts rename to packages/provider-cursor/src/server/adapter.ts index 273564e786c4..33796270205a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -11,7 +11,6 @@ import type { import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { - CursorSettings, isOrchestrationV2WorkActive, defaultInstanceIdForDriver, type ChatAttachment, @@ -30,6 +29,7 @@ import { type ProviderInstanceId, type ThreadId, } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -42,17 +42,16 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts"; -import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; +import { CursorTransportFailure } from "./transportFailure.ts"; +import { cursorSdkModelSelection } from "./sdkModel.ts"; import { discoverCursorSkills, hasCursorSkillMention, rewriteCursorSkillMentions, -} from "../../provider/Drivers/CursorSkills.ts"; +} from "./skills.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; @@ -75,7 +74,7 @@ import { subagentThreadTitle, } from "@t3tools/provider-core/server/subagentProjection"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; -export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; +export { cursorSdkModelSelection } from "./sdkModel.ts"; export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER; export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND); @@ -849,13 +848,13 @@ export interface CursorAdapterV2Options { readonly path: Path.Path; readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; } export function makeCursorAdapterV2( adapterOptions: CursorAdapterV2Options, ): ProviderAdapter.ProviderAdapterV2Shape { - const { fileSystem, path, idAllocator, runner, serverConfig } = adapterOptions; + const { fileSystem, path, idAllocator, runner, host } = adapterOptions; const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; return ProviderAdapter.ProviderAdapterV2.of({ @@ -2133,8 +2132,7 @@ export function makeCursorAdapterV2( ? rawText : rewriteCursorSkillMentions(rawText, cursorSkillNames), attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }), runOrdinal: turnInput.runOrdinal, hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, @@ -2143,10 +2141,7 @@ export function makeCursorAdapterV2( turnInput.message.attachments.filter(isProviderNativeImageAttachment), (attachment: ChatAttachment) => Effect.gen(function* () { - const path = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); + const path = host.resolveAttachmentPath(attachment); if (path === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: CursorAgentSdk.CURSOR_PROVIDER, @@ -2608,7 +2603,7 @@ export type CursorAdapterV2DriverEnv = | FileSystem.FileSystem | Path.Path | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const CursorAdapterV2Driver: ProviderAdapterDriver< CursorSettings, @@ -2624,7 +2619,7 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< const path = yield* Path.Path; const idAllocator = yield* IdAllocator.IdAllocatorV2; const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; return makeCursorAdapterV2({ instanceId: input.instanceId, settings: { @@ -2636,7 +2631,7 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< path, idAllocator, runner, - serverConfig, + host, }); }, (effect, input) => @@ -2661,7 +2656,7 @@ const layer: Layer.Layer< | FileSystem.FileSystem | Path.Path | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig + | ProviderHost.ProviderHost > = Layer.effect( ProviderAdapter.ProviderAdapterV2, Effect.gen(function* () { @@ -2670,7 +2665,7 @@ const layer: Layer.Layer< const path = yield* Path.Path; const idAllocator = yield* IdAllocator.IdAllocatorV2; const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; return makeCursorAdapterV2({ instanceId: CURSOR_DEFAULT_INSTANCE_ID, settings: DEFAULT_CURSOR_SETTINGS, @@ -2679,7 +2674,7 @@ const layer: Layer.Layer< path, idAllocator, runner, - serverConfig, + host, }); }), ); diff --git a/apps/server/src/provider/CursorAuth.test.ts b/packages/provider-cursor/src/server/auth.test.ts similarity index 99% rename from apps/server/src/provider/CursorAuth.test.ts rename to packages/provider-cursor/src/server/auth.test.ts index 6fa7133093c7..3d31211ac5ae 100644 --- a/apps/server/src/provider/CursorAuth.test.ts +++ b/packages/provider-cursor/src/server/auth.test.ts @@ -12,8 +12,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { makeCursorAuth, type CursorAuthOptions } from "./CursorAuth.ts"; -import { InMemoryCredentialStore } from "./cursorSdk.ts"; +import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts"; +import { InMemoryCredentialStore } from "./sdk.ts"; const instanceId = ProviderInstanceId.make("cursor-personal"); const owner = "owner-client"; diff --git a/apps/server/src/provider/CursorAuth.ts b/packages/provider-cursor/src/server/auth.ts similarity index 99% rename from apps/server/src/provider/CursorAuth.ts rename to packages/provider-cursor/src/server/auth.ts index 14988170b1eb..ca1c64d9b2c0 100644 --- a/apps/server/src/provider/CursorAuth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -16,8 +16,8 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; -import type { ProviderAuthController } from "./ProviderAuthService.ts"; +import { Cursor, InMemoryCredentialStore } from "./sdk.ts"; +import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; const AUTH_TIMEOUT_MS = 300_000; diff --git a/apps/server/src/provider/CursorCredentialStore.test.ts b/packages/provider-cursor/src/server/credentialStore.test.ts similarity index 62% rename from apps/server/src/provider/CursorCredentialStore.test.ts rename to packages/provider-cursor/src/server/credentialStore.test.ts index e3c0995ba072..5e176b661c46 100644 --- a/apps/server/src/provider/CursorCredentialStore.test.ts +++ b/packages/provider-cursor/src/server/credentialStore.test.ts @@ -6,28 +6,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Option from "effect/Option"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { makeCursorCredentialStore } from "./CursorCredentialStore.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as Layer from "effect/Layer"; +import { makeCursorCredentialStore } from "./credentialStore.ts"; -const makeSecrets = () => { - const data = new Map(); - return { - data, - secrets: ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, bytes) => - Effect.sync(() => { - data.set(key, bytes); - }), - remove: (key) => - Effect.sync(() => { - data.delete(key); - }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }), - }; -}; +const layerHost = Layer.provideMerge(layerTestProviderHost(), NodeServices.layer); const legacyCredentials = { version: 1 as const, @@ -74,44 +58,23 @@ it.effect.each([ const path = yield* Path.Path; const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); yield* fileSystem.writeFileString(legacyFile, legacy); - const { secrets } = makeSecrets(); + const host = yield* ProviderHost.ProviderHost; const instanceId = ProviderInstanceId.make("personal"); if (stored !== undefined) { - const current = yield* makeCursorCredentialStore(instanceId).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); - yield* secrets.set(current.binding.key, new TextEncoder().encode(stored)); + yield* (yield* host.credentials("cursor", instanceId)).set(new TextEncoder().encode(stored)); } - const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); + const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile); assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); assert.isFalse(yield* fileSystem.exists(legacyFile)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + }).pipe(Effect.scoped, Effect.provide(layerHost)), ); it.effect( "restores SDK credentials in a new controller and keeps another account when signing out", () => Effect.gen(function* () { - const data = new Map(); - const secrets = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, bytes) => - Effect.sync(() => { - data.set(key, bytes); - }), - remove: (key) => - Effect.sync(() => { - data.delete(key); - }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }); - const makeStore = (id: string) => - makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); + const host = yield* ProviderHost.ProviderHost; + const makeStore = (id: string) => makeCursorCredentialStore(ProviderInstanceId.make(id)); const personal = yield* makeStore("personal"); const work = yield* makeStore("work"); const credentials = { @@ -128,7 +91,9 @@ it.effect( ); const restored = yield* makeStore("personal"); assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); - data.set(personal.binding.key, new TextEncoder().encode("damaged credential")); + yield* (yield* host.credentials("cursor", "personal")).set( + new TextEncoder().encode("damaged credential"), + ); assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); yield* Effect.tryPromise(() => restored.store.save(credentials)); yield* Effect.tryPromise(() => restored.store.clear()); @@ -137,5 +102,5 @@ it.effect( (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, "test-only-work-key", ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(layerHost)), ); diff --git a/apps/server/src/provider/CursorCredentialStore.ts b/packages/provider-cursor/src/server/credentialStore.ts similarity index 94% rename from apps/server/src/provider/CursorCredentialStore.ts rename to packages/provider-cursor/src/server/credentialStore.ts index 56c3014deedb..70277a54e216 100644 --- a/apps/server/src/provider/CursorCredentialStore.ts +++ b/packages/provider-cursor/src/server/credentialStore.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; const Credentials = Schema.fromJsonString( Schema.Struct({ @@ -30,7 +30,7 @@ export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")( instanceId: ProviderInstanceId, legacyFile?: string, ) { - const credentials = yield* ProviderCredentialStore.make("cursor", instanceId); + const credentials = yield* (yield* ProviderHost.ProviderHost).credentials("cursor", instanceId); if (legacyFile !== undefined) { const fileSystem = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { diff --git a/apps/server/src/provider/Drivers/CursorDriver.test.ts b/packages/provider-cursor/src/server/driver.test.ts similarity index 84% rename from apps/server/src/provider/Drivers/CursorDriver.test.ts rename to packages/provider-cursor/src/server/driver.test.ts index 8aae15d1c575..b7320ce8fa98 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; @@ -11,23 +10,15 @@ import { vi } from "vite-plus/test"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { CursorDriver } from "./CursorDriver.ts"; -import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { CursorDriver } from "./driver.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { Cursor } from "../cursorSdk.ts"; -import * as ProviderHostLive from "../ProviderHostLive.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { Cursor } from "./sdk.ts"; -const layerDeps = ServerSecretStore.layer.pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-cursor-driver-copy-command-", - }), - ), +const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge( @@ -35,12 +26,6 @@ const layerDeps = ServerSecretStore.layer.pipe( open: () => Effect.die("Maintenance resolution must not open a Cursor session"), }), ), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -54,7 +39,6 @@ const layerDeps = ServerSecretStore.layer.pipe( ), ), ); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); it.layer(layerTest)("CursorDriver", (it) => { it.effect( @@ -134,7 +118,7 @@ it.layer(layerTest)("CursorDriver", (it) => { }); const threadId = ThreadId.make("cursor-browser-thread"); const modelSelection = { instanceId: input.instanceId, model: "auto" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/packages/provider-cursor/src/server/driver.ts similarity index 86% rename from apps/server/src/provider/Drivers/CursorDriver.ts rename to packages/provider-cursor/src/server/driver.ts index cf4d82bcfdb1..157b7dca5c1e 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -6,29 +6,22 @@ * * @module provider/Drivers/CursorDriver */ -import { CursorSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; -import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; +import { readCursorUsageLimits } from "./usageLimits.ts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; -import { - CursorAdapterV2Driver, - type CursorAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/CursorAdapterV2.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { - buildInitialCursorProviderSnapshot, - checkCursorProviderStatus, -} from "../CursorProvider.ts"; -import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeCursorTextGeneration } from "./textGeneration.ts"; +import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { defaultProviderContinuationIdentity, @@ -43,11 +36,10 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "@t3tools/provider-core/server/snapshotSettings"; -import { probeCursorSkills } from "./CursorSkills.ts"; -import { makeCursorAuth } from "../CursorAuth.ts"; -import * as CursorCredentialStore from "../CursorCredentialStore.ts"; -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; +import { probeCursorSkills } from "./skills.ts"; +import { makeCursorAuth } from "./auth.ts"; +import * as CursorCredentialStore from "./credentialStore.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); @@ -63,10 +55,7 @@ export type CursorDriverEnv = | FileSystem.FileSystem | Path.Path | HttpClient.HttpClient - | ProviderHost - | ServerConfig.ServerConfig - | ServerSecretStore.ServerSecretStore - | ServerSettings.ServerSettingsService; + | ProviderHost.ProviderHost; export const CursorDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -78,8 +67,7 @@ export const CursorDriver: ProviderDriver = { defaultConfig: (): CursorSettings => decodeCursorSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const serverSettings = yield* ServerSettings.ServerSettingsService; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -100,7 +88,7 @@ export const CursorDriver: ProviderDriver = { const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( instanceId, path.join( - (yield* ServerConfig.ServerConfig).stateDir, + host.paths.stateDir, "provider-auth", encodeURIComponent(instanceId), "cursor.json", @@ -214,7 +202,7 @@ export const CursorDriver: ProviderDriver = { effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? serverSettings.getSettings.pipe( + ? host.settings.get.pipe( Effect.flatMap((settings) => readCursorUsageLimits( effectiveConfig, diff --git a/apps/server/src/provider/cursorKeychainToken.test.ts b/packages/provider-cursor/src/server/keychainToken.test.ts similarity index 91% rename from apps/server/src/provider/cursorKeychainToken.test.ts rename to packages/provider-cursor/src/server/keychainToken.test.ts index 6c2e68d7b427..7f279bb60892 100644 --- a/apps/server/src/provider/cursorKeychainToken.test.ts +++ b/packages/provider-cursor/src/server/keychainToken.test.ts @@ -1,9 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { - CursorKeychainTimeoutError, - makeCachedCursorAccessTokenReader, -} from "./cursorKeychainToken.ts"; +import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts"; describe("Cursor Keychain reader", () => { it("shares concurrent reads and rechecks after the cache expires", async () => { diff --git a/apps/server/src/provider/cursorKeychainToken.ts b/packages/provider-cursor/src/server/keychainToken.ts similarity index 100% rename from apps/server/src/provider/cursorKeychainToken.ts rename to packages/provider-cursor/src/server/keychainToken.ts diff --git a/apps/server/src/provider/cursorSdk.test.ts b/packages/provider-cursor/src/server/sdk.test.ts similarity index 97% rename from apps/server/src/provider/cursorSdk.test.ts rename to packages/provider-cursor/src/server/sdk.test.ts index 6a34ee9fc40e..06a9b6229fc5 100644 --- a/apps/server/src/provider/cursorSdk.test.ts +++ b/packages/provider-cursor/src/server/sdk.test.ts @@ -2,9 +2,9 @@ import * as NodeChildProcess from "node:child_process"; import { describe, expect, it } from "vite-plus/test"; -const cursorSdkUrl = new URL("./cursorSdk.ts", import.meta.url).href; +const cursorSdkUrl = new URL("./sdk.ts", import.meta.url).href; -// Loads cursorSdk.ts in a fresh process. @cursor/sdk is stubbed so the guard +// Loads sdk.ts in a fresh process. @cursor/sdk is stubbed so the guard // can be tested without the real package. The vitest worker already has its // own unhandledRejection listener, which would hide the process-exit behavior. const probeProgram = ` diff --git a/apps/server/src/provider/cursorSdk.ts b/packages/provider-cursor/src/server/sdk.ts similarity index 100% rename from apps/server/src/provider/cursorSdk.ts rename to packages/provider-cursor/src/server/sdk.ts diff --git a/apps/server/src/provider/cursorSdkModel.ts b/packages/provider-cursor/src/server/sdkModel.ts similarity index 100% rename from apps/server/src/provider/cursorSdkModel.ts rename to packages/provider-cursor/src/server/sdkModel.ts diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/packages/provider-cursor/src/server/skills.test.ts similarity index 99% rename from apps/server/src/provider/Drivers/CursorSkills.test.ts rename to packages/provider-cursor/src/server/skills.test.ts index 382f9c6dc54e..7831e5909e0f 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -12,7 +12,7 @@ import { hasCursorSkillMention, probeCursorSkills, rewriteCursorSkillMentions, -} from "./CursorSkills.ts"; +} from "./skills.ts"; const runNode = ( effect: Effect.Effect, diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/packages/provider-cursor/src/server/skills.ts similarity index 100% rename from apps/server/src/provider/Drivers/CursorSkills.ts rename to packages/provider-cursor/src/server/skills.ts diff --git a/apps/server/src/provider/CursorProvider.test.ts b/packages/provider-cursor/src/server/status.test.ts similarity index 97% rename from apps/server/src/provider/CursorProvider.test.ts rename to packages/provider-cursor/src/server/status.test.ts index 7fe35db9ea32..32eedf9e3a9c 100644 --- a/apps/server/src/provider/CursorProvider.test.ts +++ b/packages/provider-cursor/src/server/status.test.ts @@ -2,8 +2,8 @@ import type { SDKModel } from "@cursor/sdk"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import type { CursorSettings } from "@t3tools/contracts"; -import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; +import { CursorSettings as CursorSettingsSchema } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { @@ -12,7 +12,7 @@ import { buildCursorProviderSnapshot, buildInitialCursorProviderSnapshot, checkCursorProviderStatus, -} from "./CursorProvider.ts"; +} from "./status.ts"; import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); diff --git a/apps/server/src/provider/CursorProvider.ts b/packages/provider-cursor/src/server/status.ts similarity index 99% rename from apps/server/src/provider/CursorProvider.ts rename to packages/provider-cursor/src/server/status.ts index 473a7bb8c5aa..3b00118688df 100644 --- a/apps/server/src/provider/CursorProvider.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -1,19 +1,19 @@ import type { SDKModel, SDKUser } from "@cursor/sdk"; import type { - CursorSettings, ModelCapabilities, ProviderOptionDescriptor, ServerProviderAuth, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts"; +import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts"; import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts similarity index 98% rename from apps/server/src/textGeneration/CursorTextGeneration.test.ts rename to packages/provider-cursor/src/server/textGeneration.test.ts index 4b3ce0eebbe5..8b007c9dbaf7 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -1,5 +1,6 @@ import type { RunResult } from "@cursor/sdk"; -import { CursorSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -9,7 +10,7 @@ import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { beforeEach, vi } from "vite-plus/test"; -import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; +import { makeCursorTextGeneration } from "./textGeneration.ts"; const cursorSdkMock = vi.hoisted(() => ({ create: vi.fn<(options: unknown) => Promise>(), @@ -24,7 +25,7 @@ const cursorSdkMock = vi.hoisted(() => ({ })), })); -vi.mock("../provider/cursorSdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); +vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); let hasCustomPolicy = false; const layerFs = FileSystem.layerNoop({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts similarity index 95% rename from apps/server/src/textGeneration/CursorTextGeneration.ts rename to packages/provider-cursor/src/server/textGeneration.ts index fb796438158b..924d620bbc76 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -2,20 +2,17 @@ import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import type { AgentOptions, RunResult } from "@cursor/sdk"; -import { Agent } from "../provider/cursorSdk.ts"; +import { Agent } from "./sdk.ts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { - type CursorSettings, - type ProviderSetupError, - TextGenerationError, -} from "@t3tools/contracts"; +import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; -import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; -import type { CursorAuth } from "../provider/CursorAuth.ts"; +import { cursorSdkModelSelection } from "./sdkModel.ts"; +import type { CursorAuth } from "./auth.ts"; const CURSOR_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/provider/acp/CursorTransportFailure.test.ts b/packages/provider-cursor/src/server/transportFailure.test.ts similarity index 97% rename from apps/server/src/provider/acp/CursorTransportFailure.test.ts rename to packages/provider-cursor/src/server/transportFailure.test.ts index fdde19b934d2..91d4fb9f17e1 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.test.ts +++ b/packages/provider-cursor/src/server/transportFailure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { CursorTransportFailure } from "./CursorTransportFailure.ts"; +import { CursorTransportFailure } from "./transportFailure.ts"; const diagnostic = "Error: RetriableError: WritableIterable is closed"; diff --git a/apps/server/src/provider/acp/CursorTransportFailure.ts b/packages/provider-cursor/src/server/transportFailure.ts similarity index 100% rename from apps/server/src/provider/acp/CursorTransportFailure.ts rename to packages/provider-cursor/src/server/transportFailure.ts diff --git a/apps/server/src/provider/cursorUsageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts similarity index 99% rename from apps/server/src/provider/cursorUsageLimits.test.ts rename to packages/provider-cursor/src/server/usageLimits.test.ts index 5ce2cee7fe8c..634168447d9c 100644 --- a/apps/server/src/provider/cursorUsageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; +import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; const withNodeServices = (effect: Effect.Effect) => effect.pipe(Effect.provide(NodeServices.layer)); diff --git a/apps/server/src/provider/cursorUsageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts similarity index 97% rename from apps/server/src/provider/cursorUsageLimits.ts rename to packages/provider-cursor/src/server/usageLimits.ts index 987eeda74850..24c314fe7d7d 100644 --- a/apps/server/src/provider/cursorUsageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; -import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; @@ -14,7 +15,7 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; -import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; +import { readMacCursorAccessToken } from "./keychainToken.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; diff --git a/packages/provider-cursor/src/settings.ts b/packages/provider-cursor/src/settings.ts new file mode 100644 index 000000000000..d4d5017d4f89 --- /dev/null +++ b/packages/provider-cursor/src/settings.ts @@ -0,0 +1,35 @@ +/** + * Cursor instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-cursor/settings + */ +import { CustomModelSetting, makeProviderSettingsSchema, TrimmedString } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const CursorSettings = makeProviderSettingsSchema( + { + // Off by default like Grok and OpenCode. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + // Keep V1's CLI configuration when V2 rewrites the shared settings file. + // V2's Cursor SDK does not use these fields. + binaryPath: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiEndpoint: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: [], + }, +); +export type CursorSettings = typeof CursorSettings.Type; diff --git a/packages/provider-cursor/src/testing.ts b/packages/provider-cursor/src/testing.ts new file mode 100644 index 000000000000..1357074782f5 --- /dev/null +++ b/packages/provider-cursor/src/testing.ts @@ -0,0 +1,13 @@ +/** + * Cursor internals the server's replay testkit, fixture recorder, and + * orchestration tests use directly. + * + * @module provider-cursor/testing + */ +export { + CURSOR_DEFAULT_INSTANCE_ID, + CURSOR_DRIVER_KIND, + CursorProviderCapabilitiesV2, + cursorSdkModelSelection, + makeCursorAgentOptions, +} from "./server/adapter.ts"; diff --git a/packages/provider-cursor/tsconfig.json b/packages/provider-cursor/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-cursor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index 6c9a9e113472..eb1d07af6c86 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -10,6 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; @@ -39,6 +40,7 @@ export const layerTestProviderHost = ( yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); } const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; + const credentials = new Map(); return ProviderHost.ProviderHost.of({ paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, settings: { @@ -49,6 +51,17 @@ export const layerTestProviderHost = ( shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), // Tests store attachments flat under the attachments directory by id. resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), + // Credentials live in memory for the layer's lifetime. + credentials: (namespace, bindingId) => + Effect.sync(() => { + const key = `${namespace}:${bindingId}`; + return { + binding: { owner: "t3" as const, key }, + get: Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), + set: (value: Uint8Array) => Effect.sync(() => void credentials.set(key, value)), + remove: Effect.sync(() => void credentials.delete(key)), + }; + }), }); }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39a350f843f4..85e33ad5cabe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -600,6 +603,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -736,6 +742,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -1108,6 +1117,46 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-cursor: + dependencies: + '@cursor/sdk': + specifier: 1.0.35 + version: 1.0.35 + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-muse: dependencies: '@muse-code/sdk': diff --git a/scripts/lib/cursor-sdk-packaging.test.ts b/scripts/lib/cursor-sdk-packaging.test.ts index 1eabc6664e1a..efaaa96b4954 100644 --- a/scripts/lib/cursor-sdk-packaging.test.ts +++ b/scripts/lib/cursor-sdk-packaging.test.ts @@ -79,7 +79,7 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende entry, ` import assert from 'node:assert/strict'; - import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/provider/cursorSdk.ts"))}; + import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "packages/provider-cursor/src/server/sdk.ts"))}; for (const [operation, request] of [ ['Cursor.models.list', () => Cursor.models.list({ apiKey: '' })], ['Cursor.me', () => Cursor.me({ apiKey: '' })], diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 400fc17efab4..6c5a1707854f 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-cursor/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/effect-acp/package.json", From 664bd5e524bb58b23ecab0f2e413fc2ecb4caf55 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:30:58 -0700 Subject: [PATCH 047/377] refactor(provider-acp): move the shared ACP adapter into its own package (#17354) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 1 + .../scripts/record-grok-acp-replay-fixture.ts | 13 +- apps/server/src/mcp/AcpMcpStdioBridge.ts | 57 +-- .../Adapters/AcpAdapterV2.test.ts | 351 +++++++++--------- .../Adapters/AcpAdapterV2.testkit.ts | 4 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 27 +- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 15 +- .../Adapters/AcpRegistryAdapterV2.ts | 18 +- .../Adapters/AntigravityAdapterV2.test.ts | 25 +- .../Adapters/AntigravityAdapterV2.ts | 16 +- .../src/orchestration-v2/Adapters/DevinAcp.ts | 4 +- .../Adapters/GrokAdapterV2.test.ts | 15 +- .../Adapters/GrokAdapterV2.testkit.ts | 15 +- .../Adapters/GrokAdapterV2.ts | 26 +- .../ProviderSelectionTransition.test.ts | 2 +- .../ProviderSwitch.integration.test.ts | 2 +- .../src/provider/AntigravityAuth.test.ts | 4 +- apps/server/src/provider/AntigravityAuth.ts | 8 +- .../src/provider/AntigravityProvider.test.ts | 4 +- .../src/provider/AntigravityProvider.ts | 6 +- .../src/provider/Drivers/AcpRegistryDriver.ts | 8 +- .../src/provider/Drivers/AntigravityDriver.ts | 20 +- apps/server/src/provider/GrokProvider.ts | 2 +- .../src/provider/ProviderAuthService.test.ts | 2 +- apps/server/src/provider/ProviderHostLive.ts | 1 + .../provider/acp/AcpJsonRpcConnection.test.ts | 2 +- .../src/provider/acp/AcpRegistryAuth.test.ts | 4 +- .../src/provider/acp/AcpRegistryAuth.ts | 2 +- .../src/provider/acp/AcpRegistryProbe.test.ts | 12 +- .../src/provider/acp/AcpRegistryProbe.ts | 6 +- .../src/provider/acp/AcpRegistrySupport.ts | 4 +- .../acp/AcpSessionRuntime.processTree.test.ts | 257 ++++++------- .../src/provider/acp/AntigravityAcpSupport.ts | 2 +- .../provider/acp/AntigravityProtocol.test.ts | 5 +- .../src/provider/acp/AntigravityProtocol.ts | 2 +- .../server/src/provider/acp/GrokAcpSupport.ts | 2 +- .../src/provider/acp/XAiAcpExtension.test.ts | 4 +- .../src/provider/acp/XAiAcpExtension.ts | 4 +- .../src/provider/antigravityAuthSupport.ts | 4 +- .../AntigravityTextGeneration.test.ts | 4 +- .../AntigravityTextGeneration.ts | 4 +- docs/internals/adding-a-provider.md | 2 +- package.json | 2 +- packages/provider-acp/package.json | 65 ++++ .../src/server}/AcpSessionRuntime.ts | 6 +- .../provider-acp/src/server/adapter.ts | 30 +- .../src/server/clientPolicy.test.ts | 2 +- .../provider-acp/src/server/clientPolicy.ts | 0 .../src/server/clientTerminals.test.ts | 2 +- .../src/server/clientTerminals.ts | 0 .../src/server/coreRuntimeEvents.test.ts | 2 +- .../src/server/coreRuntimeEvents.ts | 2 +- .../src/server/mcpOverAcpBridge.test.ts | 2 +- .../src/server/mcpOverAcpBridge.ts | 4 +- .../src/server/nativeLogging.test.ts | 6 +- .../provider-acp/src/server/nativeLogging.ts | 4 +- .../src/server/runtimeModel.test.ts | 2 +- .../provider-acp/src/server/runtimeModel.ts | 0 .../src/server/sessionConfig.test.ts | 2 +- .../provider-acp/src/server/sessionConfig.ts | 2 +- .../provider-acp/src/server/stderr.test.ts | 2 +- .../provider-acp/src/server/stderr.ts | 0 packages/provider-acp/tsconfig.json | 7 + .../provider-core/src/server/ProviderHost.ts | 2 + .../src/server/managedProvider.test.ts | 1 + packages/provider-testing/src/host.ts | 8 +- packages/shared/package.json | 4 + packages/shared/src/mcpResponsePayloads.ts | 65 ++++ pnpm-lock.yaml | 37 ++ scripts/release-smoke.ts | 1 + 70 files changed, 673 insertions(+), 553 deletions(-) create mode 100644 packages/provider-acp/package.json rename {apps/server/src/provider/acp => packages/provider-acp/src/server}/AcpSessionRuntime.ts (99%) rename apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts => packages/provider-acp/src/server/adapter.ts (99%) rename apps/server/src/provider/acp/AcpClientPolicy.test.ts => packages/provider-acp/src/server/clientPolicy.test.ts (99%) rename apps/server/src/provider/acp/AcpClientPolicy.ts => packages/provider-acp/src/server/clientPolicy.ts (100%) rename apps/server/src/provider/acp/AcpClientTerminals.test.ts => packages/provider-acp/src/server/clientTerminals.test.ts (99%) rename apps/server/src/provider/acp/AcpClientTerminals.ts => packages/provider-acp/src/server/clientTerminals.ts (100%) rename apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts => packages/provider-acp/src/server/coreRuntimeEvents.test.ts (99%) rename apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts => packages/provider-acp/src/server/coreRuntimeEvents.ts (99%) rename apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts => packages/provider-acp/src/server/mcpOverAcpBridge.test.ts (98%) rename apps/server/src/mcp/AcpMcpOverAcpBridge.ts => packages/provider-acp/src/server/mcpOverAcpBridge.ts (98%) rename apps/server/src/provider/acp/AcpNativeLogging.test.ts => packages/provider-acp/src/server/nativeLogging.test.ts (97%) rename apps/server/src/provider/acp/AcpNativeLogging.ts => packages/provider-acp/src/server/nativeLogging.ts (97%) rename apps/server/src/provider/acp/AcpRuntimeModel.test.ts => packages/provider-acp/src/server/runtimeModel.test.ts (99%) rename apps/server/src/provider/acp/AcpRuntimeModel.ts => packages/provider-acp/src/server/runtimeModel.ts (100%) rename apps/server/src/provider/acp/AcpSessionConfig.test.ts => packages/provider-acp/src/server/sessionConfig.test.ts (99%) rename apps/server/src/provider/acp/AcpSessionConfig.ts => packages/provider-acp/src/server/sessionConfig.ts (98%) rename apps/server/src/provider/acp/AcpStderr.test.ts => packages/provider-acp/src/server/stderr.test.ts (98%) rename apps/server/src/provider/acp/AcpStderr.ts => packages/provider-acp/src/server/stderr.ts (100%) create mode 100644 packages/provider-acp/tsconfig.json create mode 100644 packages/shared/src/mcpResponsePayloads.ts diff --git a/apps/server/package.json b/apps/server/package.json index de20a01de3b9..5e731012c583 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "@napi-rs/keyring": "^1.3.0", "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", + "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index 23d687eb9130..30db5e0ef85a 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -24,14 +24,15 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import * as ServerConfig from "../src/config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, grokLaunchRuntimeMode, makeGrokAdapterV2, } from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; -import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts"; +import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; @@ -40,7 +41,6 @@ import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; -import { makeReplayServerConfig } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; @@ -455,7 +455,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, // Production's runtime factory, with the protocol logger teeing raw lines. @@ -496,10 +496,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s ).pipe( Layer.provide( Layer.mergeAll( - Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`grok-record-${fixtureName}`), - ).pipe(Layer.provide(NodeServices.layer)), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), NodeServices.layer, IdAllocator.layer, ), diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 5c7bfe6889d5..02e6cdeac607 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; +import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; /** * Stdio-to-HTTP bridge for T3's MCP endpoint. @@ -63,58 +64,6 @@ function protocolVersionOf(entry: unknown): string | null { return typeof version === "string" && version.length > 0 ? version : null; } -async function* sseDataLines(response: Response): AsyncGenerator { - if (response.body === null) return; - const decoder = new TextDecoder(); - let buffered = ""; - for await (const chunk of response.body) { - buffered += decoder.decode(chunk as Uint8Array, { stream: true }); - let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); - while (separatorIndex !== -1) { - const rawEvent = buffered.slice(0, separatorIndex); - buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); - const data = rawEvent - .split(/\r?\n/u) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice("data:".length).trimStart()) - .join("\n"); - if (data.length > 0) yield data; - separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); - } - } -} - -const discardResponseBody = (response: Response): Effect.Effect => - Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); - -/** - * Every JSON-RPC payload carried by a response, in arrival order: nothing for - * notification acknowledgements, each SSE `data:` event as it streams in, or - * the single JSON body. - */ -export function responsePayloads(response: Response): Stream.Stream { - if (response.status === 202 || response.status === 204) { - return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); - } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("text/event-stream")) { - return Stream.fromAsyncIterable(sseDataLines(response), bridgeError).pipe( - Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: bridgeError })), - ); - } - return Stream.unwrap( - Effect.tryPromise({ try: () => response.text(), catch: bridgeError }).pipe( - Effect.flatMap((text) => - text.trim().length === 0 - ? Effect.succeed(Stream.empty) - : Effect.try({ try: () => JSON.parse(text) as unknown, catch: bridgeError }).pipe( - Effect.map((payload) => Stream.fromIterable([payload])), - ), - ), - ), - ); -} - export interface AcpMcpToolCallOptions { readonly endpoint: string; readonly authorization: string; @@ -164,7 +113,7 @@ export function callAcpMcpTool( new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - const payloads = yield* Stream.runCollect(responsePayloads(response)); + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); for (const payload of payloads) { protocolVersion = protocolVersionOf(payload) ?? protocolVersion; } @@ -271,7 +220,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. } return yield* discardResponseBody(response); } - yield* Stream.runForEach(responsePayloads(response), handleServerPayload); + yield* Stream.runForEach(responsePayloads(response, bridgeError), handleServerPayload); }).pipe( Effect.catchCause((cause) => { if (envelope.id === undefined) return Effect.void; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c90826527176..5e225d570d7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -54,9 +54,10 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, @@ -90,7 +91,7 @@ import { type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, type AcpAdapterV2SubagentUpdate, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; import { @@ -100,11 +101,9 @@ import { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-acp-v2-adapter-", -}).pipe(Layer.provide(NodeServices.layer)); +const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); -const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); +const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -616,7 +615,7 @@ describe("AcpAdapterV2", () => { instanceId, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ProviderDriverKind.make("acpRegistry"), @@ -713,7 +712,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation().pipe( Effect.provideService(HostProcessIsExecutable, true), ); @@ -753,7 +752,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ @@ -786,7 +785,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -816,7 +815,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const policy = (interactionMode: "default" | "plan") => @@ -913,7 +912,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -941,7 +940,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); @@ -1049,7 +1048,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1195,7 +1194,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1515,7 +1514,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1544,7 +1543,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-fidelity"); @@ -1709,7 +1708,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1730,7 +1729,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-eager-resume"); @@ -1794,7 +1793,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1817,7 +1816,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-eager-resume"); @@ -1940,7 +1939,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1969,7 +1968,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unexpected-termination"); @@ -2023,7 +2022,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2058,7 +2057,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); @@ -2108,7 +2107,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2137,7 +2136,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); @@ -2164,7 +2163,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2209,7 +2208,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); @@ -2242,7 +2241,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2286,7 +2285,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2454,7 +2453,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2484,7 +2483,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-no-client-fs"); @@ -2551,7 +2550,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2591,7 +2590,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2688,7 +2687,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2704,7 +2703,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-native-thread"); @@ -2750,7 +2749,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2788,7 +2787,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-session"); @@ -2917,7 +2916,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3001,7 +3000,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -3078,7 +3077,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3120,7 +3119,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); @@ -3194,7 +3193,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3211,7 +3210,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-idle-finalizer"); @@ -3245,7 +3244,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3261,7 +3260,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3300,7 +3299,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3316,7 +3315,7 @@ describe("AcpAdapterV2", () => { crypto: yield* Crypto.Crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3381,7 +3380,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3397,7 +3396,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsupported-option"); @@ -3430,7 +3429,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3447,7 +3446,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); @@ -3541,7 +3540,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3568,7 +3567,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-empty-successful-bash"); @@ -3688,7 +3687,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const protocolEvents = yield* Queue.unbounded(); const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {}; @@ -3715,7 +3714,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-native-cancel"); @@ -3802,7 +3801,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3824,7 +3823,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-permission"); @@ -3903,7 +3902,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3930,7 +3929,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-response-wins-permission"); @@ -4002,7 +4001,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4029,7 +4028,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-reordered-elicitation"); @@ -4091,7 +4090,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4111,7 +4110,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); @@ -4159,7 +4158,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4187,7 +4186,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4267,7 +4266,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4292,7 +4291,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { afterNativeResponseTransportClosed: () => @@ -4381,7 +4380,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4426,7 +4425,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4522,7 +4521,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4564,7 +4563,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-pending-response-cancel"); @@ -4638,7 +4637,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4679,7 +4678,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); @@ -4743,7 +4742,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4778,7 +4777,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); @@ -4836,7 +4835,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4875,7 +4874,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-response-ack"); @@ -4929,7 +4928,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4964,7 +4963,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); @@ -5042,7 +5041,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5064,7 +5063,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-timeout"); @@ -5145,7 +5144,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5171,7 +5170,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-double-stop"); @@ -5243,7 +5242,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5281,7 +5280,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); @@ -5339,7 +5338,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5391,7 +5390,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-subagent-carryover"); @@ -5487,7 +5486,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5537,7 +5536,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5634,7 +5633,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5707,7 +5706,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5871,7 +5870,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5958,7 +5957,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6094,7 +6093,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6157,7 +6156,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -6313,7 +6312,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6355,7 +6354,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6461,7 +6460,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6520,7 +6519,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -6713,7 +6712,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6774,7 +6773,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7001,7 +7000,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7072,7 +7071,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7242,7 +7241,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7316,7 +7315,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7471,7 +7470,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7533,7 +7532,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7742,7 +7741,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7803,7 +7802,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -7984,7 +7983,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8053,7 +8052,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-steer"); @@ -8160,7 +8159,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8192,7 +8191,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); @@ -8250,7 +8249,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8298,7 +8297,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -8444,7 +8443,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8484,7 +8483,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8591,7 +8590,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8630,7 +8629,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8722,7 +8721,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8799,7 +8798,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); @@ -9026,7 +9025,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9087,7 +9086,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -9223,7 +9222,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9298,7 +9297,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); @@ -9393,7 +9392,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9431,7 +9430,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9524,7 +9523,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9554,7 +9553,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9644,7 +9643,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9690,7 +9689,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -9853,7 +9852,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9897,7 +9896,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-injected-report-hold"); @@ -10019,7 +10018,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10078,7 +10077,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10221,7 +10220,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10276,7 +10275,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10461,7 +10460,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10523,7 +10522,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10680,7 +10679,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10746,7 +10745,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -10895,7 +10894,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10983,7 +10982,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11267,7 +11266,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11306,7 +11305,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11421,7 +11420,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11490,7 +11489,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11752,7 +11751,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11802,7 +11801,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -11939,7 +11938,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11993,7 +11992,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12183,7 +12182,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12235,7 +12234,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12373,7 +12372,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12395,7 +12394,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); @@ -12619,7 +12618,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12668,7 +12667,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: (request) => @@ -12837,7 +12836,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12861,7 +12860,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); @@ -12926,7 +12925,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12963,7 +12962,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); @@ -13039,7 +13038,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13122,7 +13121,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, testHooks: { afterHardTeardownTransportDrained: () => @@ -13401,7 +13400,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13428,7 +13427,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); @@ -13526,7 +13525,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13563,7 +13562,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); @@ -13660,7 +13659,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13714,7 +13713,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -13937,7 +13936,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13987,7 +13986,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); @@ -14091,7 +14090,7 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -14136,7 +14135,7 @@ describe("AcpAdapterV2", () => { }, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-active-in-process"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index 960ac6f57f01..8a4310c3d542 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -13,9 +13,9 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; -import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "./AcpAdapterV2.ts"; +import { ACP_PROTOCOL, type AcpAdapterV2RuntimeInput } from "@t3tools/provider-acp/server/adapter"; export const AcpReplayTranscript = Schema.Struct({ provider: ProviderDriverKind, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 85ae7368ee67..fa861a22dde7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -14,15 +14,16 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ServerSettings from "../../serverSettings.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, } from "../../provider/acp/AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; -import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { decodeAcpReplayTranscript, @@ -43,9 +44,7 @@ const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( AcpRegistryAdapterV2Driver.configSchema, ); -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-acp-registry-v2-adapter-", -}).pipe(Layer.provide(NodeServices.layer)); +const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); const layerRegistry = Layer.succeed( HttpClient.HttpClient, @@ -86,7 +85,7 @@ const layerRegistry = Layer.succeed( const layerTest = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, - layerServerConfig, + layerHost, layerRegistry, ServerSettings.layerTest(), ); @@ -202,7 +201,7 @@ describe("AcpRegistryAdapterV2", () => { fileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, resolver: { resolve: () => Effect.die("the runtime is injected") }, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, makeRuntime: makeAcpReplayRuntime({ transcript, statusPath, @@ -272,7 +271,7 @@ describe("AcpRegistryAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); @@ -291,7 +290,7 @@ describe("AcpRegistryAdapterV2", () => { fileSystem, idAllocator, resolver: { resolve: () => Effect.die("the runtime is injected") }, - serverConfig, + host, makeRuntime: (input) => Effect.gen(function* () { clientCapabilities = input.clientCapabilities; @@ -348,13 +347,13 @@ describe("AcpRegistryAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ - cacheDir: serverConfig.providerStatusCacheDir, - toolsDir: serverConfig.baseDir + "/tools", + cacheDir: host.paths.providerStatusCacheDir, + toolsDir: host.paths.baseDir + "/tools", registryUrl, }); const settings = yield* decodeAcpRegistryAdapterSettings({ @@ -429,7 +428,7 @@ describe("AcpRegistryAdapterV2", () => { })), ), }, - serverConfig, + host, }); const threadId = ThreadId.make("thread-acp-registry-fixture"); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 7d829741862d..9ebe08e40ec1 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -9,12 +9,12 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { type AcpReplayTranscript, AcpReplayTranscriptDecodeError, @@ -37,10 +37,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( transcript: AcpReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`acp-registry-${transcript.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { @@ -49,7 +46,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const replayGate = options.replayGate; const replayDir = yield* fileSystem .makeTempDirectory({ @@ -71,7 +68,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( resolver: { resolve: () => Effect.die("ACP registry resolver must not run during replay"), }, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), makeRuntime: makeAcpReplayRuntime({ transcript, @@ -86,7 +83,7 @@ function layerAcpRegistryProviderAdapterRegistryReplay( return [adapter]; }), ).pipe( - Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), + Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), // Held inbound lines must not outlive the scenario and wedge teardown. Layer.merge( Layer.effectDiscard( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index f1a1ef3a583f..d188f9c9266f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -21,7 +21,7 @@ import type * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { normalizeAcpRegistryCommands, normalizeAcpRegistryLiveConfiguration, @@ -29,8 +29,8 @@ import { } from "../../provider/acp/AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; -import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -46,7 +46,7 @@ import { type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; export const ACP_REGISTRY_PROVIDER = ProviderDriverKind.make("acpRegistry"); export const ACP_REGISTRY_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(ACP_REGISTRY_PROVIDER); @@ -65,7 +65,7 @@ export interface AcpRegistryAdapterV2Options { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly resolver: Pick; readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly makeRuntime?: ( input: AcpAdapterV2RuntimeInput, @@ -243,7 +243,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, // Per-agent exception (see the note above registerMistralVibeAcpExtensions): // Devin runs commands through client terminals and has no ask mode over @@ -269,7 +269,7 @@ export type AcpRegistryAdapterV2DriverEnv = | IdAllocator.IdAllocatorV2 | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< AcpRegistrySettings, @@ -287,7 +287,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; const runtimeCoordinator = yield* Effect.serviceOption( @@ -305,7 +305,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< ...(Option.isSome(runtimeCoordinator) ? { runtimeCoordinator: runtimeCoordinator.value } : {}), - serverConfig, + host, selfInvocation, nativeLogging: (threadId) => makeNativeLogger({ diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index ed00f33728b7..4ce55c783307 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -22,8 +22,9 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; -import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; @@ -38,7 +39,7 @@ const flavor = makeAntigravityAcpAdapterFlavor({ fileSystem: undefined as never, path: undefined as never, idAllocator: undefined as never, - serverConfig: undefined as never, + host: undefined as never, selfInvocation: undefined as never, makeRuntime: () => Effect.die("not spawned in this test"), withProcess: (_stop, task) => task, @@ -129,9 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => { const layerSession = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe( - Layer.provide(NodeServices.layer), - ), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), ); describe("AntigravityAdapterV2 client file system", () => { @@ -140,7 +139,7 @@ describe("AntigravityAdapterV2 client file system", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); @@ -157,7 +156,7 @@ describe("AntigravityAdapterV2 client file system", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -193,7 +192,7 @@ describe("AntigravityAdapterV2 client file system", () => { }); const outsideFile = path.join(outside, "secret.txt"); yield* fileSystem.writeFileString(outsideFile, "secret"); - const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt"); + const attachment = path.join(host.paths.attachmentsDir, "pasted.txt"); yield* fileSystem.writeFileString(attachment, "pasted"); const threadId = ThreadId.make("thread-antigravity-containment"); @@ -294,7 +293,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -309,7 +308,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -431,7 +430,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -459,7 +458,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( fileSystem, path, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig, + host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 3d8f8f75091a..4263618cf32c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -14,9 +14,9 @@ import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; import * as EffectAcpErrors from "effect-acp/errors"; -import type { ServerConfig } from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { AntigravityAuth } from "../../provider/AntigravityAuth.ts"; -import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { antigravityPermissionMode, applyAntigravityAcpModelSelection, @@ -40,7 +40,7 @@ import { makeAcpAdapterV2, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; const ANTIGRAVITY_PROVIDER = ProviderDriverKind.make("antigravity"); @@ -68,7 +68,7 @@ export interface AntigravityAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly idAllocator: IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; /** Spawns the official agent with the instance's Google profile. */ readonly makeRuntime: ( input: Omit, @@ -125,9 +125,7 @@ export function makeAntigravityAcpAdapterFlavor( // the turn text references. It is a leaf directory of uploads. A session // without a workspace gets no workspace root rather than the server's cwd. const antigravityClientFileRoots = (cwd: string | null) => - cwd === null - ? [options.serverConfig.attachmentsDir] - : [cwd, options.serverConfig.attachmentsDir]; + cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir]; const makeRuntime = (input: AcpAdapterV2RuntimeInput) => Effect.gen(function* () { // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the @@ -139,7 +137,7 @@ export function makeAntigravityAcpAdapterFlavor( options.makeRuntime({ ...input, clientFileSystem: true, - additionalDirectories: [options.serverConfig.attachmentsDir], + additionalDirectories: [options.host.paths.attachmentsDir], }), ) .pipe(Effect.provideService(Scope.Scope, scope)); @@ -233,7 +231,7 @@ export function makeAntigravityAdapterV2(options: AntigravityAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts b/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts index 23c3b896900c..6f78d2506adc 100644 --- a/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts +++ b/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts @@ -1,6 +1,6 @@ import type * as AcpSchema from "effect-acp/compat"; -import type { AcpToolCallState } from "../../provider/acp/AcpRuntimeModel.ts"; -import type { AcpAdapterV2SubagentUpdate } from "./AcpAdapterV2.ts"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; +import type { AcpAdapterV2SubagentUpdate } from "@t3tools/provider-acp/server/adapter"; function record(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 4284de473a17..23ebb1b4f2a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -22,7 +22,8 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProjectStore from "../ProjectStore.ts"; import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; @@ -30,13 +31,13 @@ import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegist import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import * as RuntimePolicy from "../RuntimePolicy.ts"; -import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; +import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; import { AcpProviderCapabilitiesV2, acpCompletedTurnShouldTerminalizeTool, acpSubagentStatusBlocksTurnSettlement, acpSupportsImagePrompts, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; import { makeGrokAcpAdapterFlavor, makeGrokAdapterV2, @@ -311,10 +312,8 @@ describe("Grok permission prompts", () => { }); describe("Grok launch permission mode", () => { - const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-grok-v2-launch-", - }).pipe(Layer.provide(NodeServices.layer)); - const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerServerConfig); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); + const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); // Opens a session through the adapter's own Grok runtime factory and returns // the argv it tried to launch. The spawn fails after recording, so no @@ -342,7 +341,7 @@ describe("Grok launch permission mode", () => { crypto: yield* Crypto.Crypto, fileSystem: yield* FileSystem.FileSystem, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), }); yield* adapter diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index b770efd77136..58446793decd 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -10,7 +10,8 @@ import { ChildProcessSpawner } from "effect/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; -import * as ServerConfig from "../../config.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META, @@ -21,7 +22,6 @@ import * as ProviderContinuationRequests from "@t3tools/provider-core/server/con import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import { makeReplayServerConfig } from "../testkit/ProviderReplayHarness.ts"; import { type AcpReplayTranscript, AcpReplayTranscriptDecodeError, @@ -37,10 +37,7 @@ function layerGrokProviderAdapterRegistryReplay( transcript: AcpReplayTranscript, options: { readonly replayGate?: ProviderReplayGate } = {}, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`grok-${transcript.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); + const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { @@ -50,7 +47,7 @@ function layerGrokProviderAdapterRegistryReplay( const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; // Same queue the continuation worker drains when the fixture runs it. const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const replayGate = options.replayGate; @@ -72,7 +69,7 @@ function layerGrokProviderAdapterRegistryReplay( crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation: yield* resolveSelfInvocation(), // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel // metadata and the x.ai prompt-completion race, so replay sends what @@ -102,7 +99,7 @@ function layerGrokProviderAdapterRegistryReplay( return [adapter]; }), ).pipe( - Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)), + Layer.provide(Layer.mergeAll(layerHost, NodeServices.layer, IdAllocator.layer)), // Held inbound lines must not outlive the scenario and wedge teardown. Layer.merge( Layer.effectDiscard( diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index de3c77a696ac..e44c2da421a9 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -23,8 +23,8 @@ import type * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ServerConfig from "../../config.ts"; -import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -54,8 +54,8 @@ import { XAiExitPlanModeRequest, } from "../../provider/acp/XAiAcpExtension.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; @@ -71,7 +71,7 @@ import { type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, type AcpAdapterV2RuntimeInput, -} from "./AcpAdapterV2.ts"; +} from "@t3tools/provider-acp/server/adapter"; export const GROK_PROVIDER = ProviderDriverKind.make("grok"); const GROK_DRIVER_KIND = GROK_PROVIDER; @@ -117,7 +117,7 @@ export interface GrokAdapterV2Options { readonly selfInvocation: SelfInvocation; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly testHooks?: Parameters[0]["testHooks"]; @@ -337,7 +337,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { crypto: options.crypto, fileSystem: options.fileSystem, idAllocator: options.idAllocator, - serverConfig: options.serverConfig, + host: options.host, selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined @@ -354,7 +354,7 @@ export type GrokAdapterV2DriverEnv = | IdAllocator.IdAllocatorV2 | Path.Path | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderHost.ProviderHost; export const GrokAdapterV2Driver: ProviderAdapterDriver = { driverKind: GROK_DRIVER_KIND, @@ -370,7 +370,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver @@ -417,7 +417,7 @@ const layer: Layer.Layer< | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig + | ProviderHost.ProviderHost > = Layer.effect( ProviderAdapter.ProviderAdapterV2, Effect.gen(function* () { @@ -429,7 +429,7 @@ const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); return makeGrokAdapterV2({ @@ -441,7 +441,7 @@ const layer: Layer.Layer< crypto, fileSystem, idAllocator, - serverConfig, + host, selfInvocation, continuationRequests, nativeLogging: (threadId) => diff --git a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts index 4a6d6a6a91d1..adc4ae1543a4 100644 --- a/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSelectionTransition.test.ts @@ -6,7 +6,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; -import { AcpProviderCapabilitiesV2 } from "./Adapters/AcpAdapterV2.ts"; +import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; const selection = (model: string, effort = "medium"): ModelSelection => ({ diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index b71043aabc4e..e7633781036b 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -42,7 +42,7 @@ import { CodexProviderCapabilitiesV2, canReuseCodexContextUsage, } from "../Adapters/CodexAdapterV2.ts"; -import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; +import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index 39deaa4ecd39..46bc2aeec280 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -17,7 +17,7 @@ import { type AntigravityAuth, type AntigravityAuthRuntime, } from "./AntigravityAuth.ts"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const instanceId = ProviderInstanceId.make("antigravity-auth-test"); const owner = "t3-auth-session-owner"; @@ -31,7 +31,7 @@ const initialized = { authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], agentCapabilities: { auth: { logout: {} } }, } satisfies AcpSchema.InitializeResponse; -const started: AcpSessionRuntimeStartResult = { +const started: AcpSessionRuntime.AcpSessionRuntimeStartResult = { sessionId: "native-session", initializeResult: initialized, sessionSetupResult: { diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index d25ac40f32e7..d711abe7c1dc 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -19,7 +19,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as AcpErrors from "effect-acp/errors"; -import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { parseAntigravityAuthorizationUrl, type AntigravityAuthorizationUrl, @@ -66,19 +66,19 @@ export interface AntigravityAuth { } export type AntigravityAuthRuntime = Pick< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], "initialize" | "start" | "request" >; export interface AntigravityAuthOptions< - Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"], + Runtime extends AntigravityAuthRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"], > { readonly instanceId: ProviderInstanceId; readonly makeRuntime: (input: { readonly onAuthorizationUrl?: (url: string) => Effect.Effect; }) => Effect.Effect; readonly onAuthenticated: ( - result: AcpSessionRuntimeStartResult, + result: AcpSessionRuntime.AcpSessionRuntimeStartResult, runtime: Runtime, ) => Effect.Effect; readonly onSignedOut: Effect.Effect; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 69502047f73d..f1728108dfae 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -23,7 +23,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { buildAntigravityModelsFromSession, makeAntigravityProvider, @@ -98,7 +98,7 @@ const started = { initializeResult, sessionSetupResult, modelConfigId: "model", -} satisfies AcpSessionRuntimeStartResult; +} satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult; const commands = [ { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 143b6522bd46..ce4663a9be41 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -18,7 +18,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -38,7 +38,7 @@ const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; type SessionSetupResult = Pick< - AcpSessionRuntimeStartResult["sessionSetupResult"], + AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"], "configOptions" | "models" >; @@ -256,7 +256,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( - started: AcpSessionRuntimeStartResult, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, cwd?: string, ) { const before = yield* SubscriptionRef.get(metadata); diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index bdd5c0449a2c..be570db9160c 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -22,7 +22,6 @@ import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/process"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv, @@ -489,7 +488,6 @@ export const AcpRegistryDriver: ProviderDriver confirmedAuthentication diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f354dc4b4aa7..fa075fa28dfc 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -19,7 +19,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { AcpError } from "effect-acp/errors"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; import { isAntigravityTextGenerationAvailable, makeAntigravityTextGeneration, @@ -40,7 +39,7 @@ import { makeAntigravityAcpRuntime, type AntigravityAcpRuntimeInput, } from "../acp/AntigravityAcpSupport.ts"; -import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; import { removeAntigravityRuntimeTempDirs, @@ -49,7 +48,7 @@ import { import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; @@ -76,8 +75,7 @@ export type AntigravityDriverEnv = | IdAllocator.IdAllocatorV2 | ModelManifest.ModelManifest | Path.Path - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderEventLoggers.ProviderEventLoggers; /** Each instance owns its Google profile. Executable releases are shared by the environment. */ export const AntigravityDriver: ProviderDriver = { @@ -91,7 +89,7 @@ export const AntigravityDriver: ProviderDriver, ): Effect.fn.Return< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], AcpError | ProviderSetupError, Scope.Scope > { @@ -303,8 +301,8 @@ export const AntigravityDriver: ProviderDriver, + started: AcpSessionRuntime.AcpSessionRuntimeStartResult, + runtime: Pick, ): Effect.Effect => Effect.gen(function* () { yield* provider.onSessionStarted(started); @@ -405,7 +403,7 @@ export const AntigravityDriver: ProviderDriver[0] + | Parameters[0] | undefined; return { initialize: () => Effect.succeed(initialized), diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/apps/server/src/provider/acp/AcpRegistryAuth.ts index 6a885b711120..3232b7b370a2 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.ts +++ b/apps/server/src/provider/acp/AcpRegistryAuth.ts @@ -20,7 +20,7 @@ import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; type Runtime = Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts index 9d5288c957ee..37066157fc0b 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.test.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; -import type { AcpSessionRuntimeStartResult } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { acpRegistryProbeFailure, acpRegistryProbeResult, @@ -68,7 +68,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result).toEqual({ instanceId, @@ -186,7 +186,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([ { id: "sonnet", name: "Sonnet", description: "Balanced" }, @@ -216,7 +216,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]); expect(result.currentModelId).toBeNull(); @@ -270,7 +270,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toEqual([ { id: codexModel, name: "GPT-6.1 Sol", description: null }, @@ -312,7 +312,7 @@ describe("ACP Registry probe", () => { ], }, modelConfigId: "model", - } satisfies AcpSessionRuntimeStartResult); + } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); expect(result.models).toHaveLength(256); expect(result.currentModelId).toBeNull(); diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/apps/server/src/provider/acp/AcpRegistryProbe.ts index 0ee7ea1a878b..9fc142c35dc3 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/apps/server/src/provider/acp/AcpRegistryProbe.ts @@ -25,10 +25,10 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; -import { parseSessionModeState } from "./AcpRuntimeModel.ts"; -import { acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; +import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; +import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const MAX_AUTH_METHODS = 32; const MAX_MODELS = 256; diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index c62f93dae400..dd4ef66f7876 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -43,7 +43,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as ServerSettings from "../../serverSettings.ts"; -import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; @@ -444,7 +444,7 @@ export function resolveAcpRegistryDistribution(input: { export interface ResolvedAcpRegistryAgent { readonly agent?: AcpRegistryAgent; readonly distribution: AcpRegistryDistributionKind | "local"; - readonly spawn: AcpSpawnInput; + readonly spawn: AcpSessionRuntime.AcpSpawnInput; } export type AcpRegistryInspection = diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 9067ee93f096..13a7dd6c2c81 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -11,29 +11,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; -import { - capturePosixOwnershipLedger, - isSafeCgroupPath, - makeLinuxCgroupController, - makePosixProcessTreeController, - observePosixOwnershipLedger, - observePosixOwnershipLedgerContinuously, - parseCgroup2Mount, - parseCgroup2Mounts, - parseUnifiedCgroupPath, - posixProcessIsZombie, - resolveLinuxCgroupTargetCommand, - signalLinuxCgroupRootTerm, - sweepStaleLinuxCgroupSiblings, - terminateLinuxCgroupLease, - terminatePosixOwnedProcessTree, - wrapCommandForLinuxCgroup, - type AcpOwnedPosixProcess, - type AcpLinuxCgroupLease, - type AcpPosixOwnershipRoot, - type AcpPosixProcessIdentity, - type AcpPosixProcessTreeController, -} from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const threadSpawnHelperSource = NodeURL.fileURLToPath( new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), @@ -46,7 +24,7 @@ const identity = ( sid: number, startTime = String(pid), state = "S", -): AcpPosixProcessIdentity => ({ +): AcpSessionRuntime.AcpPosixProcessIdentity => ({ executable: `/proc/${pid}/exe`, pgid, pid, @@ -57,16 +35,16 @@ const identity = ( }); function makeController(input: { - readonly processes: ReadonlyArray; + readonly processes: ReadonlyArray; readonly onProcess?: ( - processes: Map, + processes: Map, pid: number, signal: NodeJS.Signals, ) => void; }) { const processes = new Map(input.processes.map((process) => [process.pid, process])); const signals: Array = []; - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { childPidsOf: (pid) => [...processes.values()] .filter((process) => process.ppid === pid) @@ -96,19 +74,23 @@ function processExists(pid: number): boolean { describe("terminatePosixOwnedProcessTree", () => { it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { - expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope"); - expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); - expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true); - expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false); - expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); + expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe( + "/user.slice/app.scope", + ); + expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope")).toBe(true); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/../escape")).toBe(false); + expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); expect( - parseCgroup2Mount( + AcpSessionRuntime.parseCgroup2Mount( "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", ), ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); - expect(parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")).toBeUndefined(); expect( - parseCgroup2Mounts( + AcpSessionRuntime.parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n"), + ).toBeUndefined(); + expect( + AcpSessionRuntime.parseCgroup2Mounts( "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" + "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n", ), @@ -140,7 +122,7 @@ describe("terminatePosixOwnedProcessTree", () => { const unrelated = writeSibling("other-lease", "0"); const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid; try { - sweepStaleLinuxCgroupSiblings(parent, { + AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { isProcessAlive, readPopulated: (path) => populatedByPath.get(path), remove: (path) => { @@ -155,7 +137,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect(NodeFS.existsSync(unrelated)).toBe(true); // Missing populated state must not remove a sibling. writeSibling(`t3-acp-${deadPid}-eeee`, null); - sweepStaleLinuxCgroupSiblings(parent, { + AcpSessionRuntime.sweepStaleLinuxCgroupSiblings(parent, { isProcessAlive, readPopulated: (path) => populatedByPath.get(path), remove: (path) => { @@ -174,7 +156,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("preserves exact argv and strips wrapper-only environment before exec", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) !== "linux") return; - const lease = makeLinuxCgroupController().create(); + const lease = AcpSessionRuntime.makeLinuxCgroupController().create(); if (lease === undefined) return; const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); @@ -192,17 +174,27 @@ describe("terminatePosixOwnedProcessTree", () => { NodeFS.symlinkSync(process.execPath, bareGrok); NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok")); NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok")); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch }), + ).toBe(bareGrok); + expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( + bareGrok, + ); // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone // (bareGrok lives only in cwd / PATH=scratch). - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined })).toBeUndefined(); - expect(resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined })).toBeDefined(); - expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "relative-bin" })).toBe( - NodePath.join(relativeBin, "grok"), - ); expect( - resolveLinuxCgroupTargetCommand("grok", scratch, { + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), + ).toBeUndefined(); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + ).toBeDefined(); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { + PATH: "relative-bin", + }), + ).toBe(NodePath.join(relativeBin, "grok")); + expect( + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: `directory-bin${NodePath.delimiter}valid-bin`, }), ).toBe(NodePath.join(validBin, "grok")); @@ -214,7 +206,7 @@ describe("terminatePosixOwnedProcessTree", () => { " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,", "}));", ].join("\n"); - const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, linkedNode, [ "-e", target, outputPath, @@ -236,7 +228,7 @@ describe("terminatePosixOwnedProcessTree", () => { '{"args":["space value","single\'quote","double\\"quote"]}', ); } finally { - yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); NodeFS.rmSync(scratch, { recursive: true, force: true }); } }), @@ -251,9 +243,11 @@ describe("terminatePosixOwnedProcessTree", () => { const outputPath = NodePath.join(scratch, "argv output"); NodeFS.mkdirSync(leasePath); NodeFS.writeFileSync(NodePath.join(leasePath, "cgroup.procs"), ""); - const relativePath = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); + const relativePath = AcpSessionRuntime.parseUnifiedCgroupPath( + NodeFS.readFileSync("/proc/self/cgroup", "utf8"), + ); expect(relativePath).toBeDefined(); - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => true, path: leasePath, @@ -264,7 +258,7 @@ describe("terminatePosixOwnedProcessTree", () => { }; const packagedExecPath = vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/sh"); try { - const wrapped = wrapCommandForLinuxCgroup(lease, "/bin/sh", [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, "/bin/sh", [ "-c", 'printf "%s\\n" "$0" "$1" "$2" "$3" "${ELECTRON_RUN_AS_NODE-}" "${T3_ACP_CGROUP_WRAPPER-}" > "$4"', "packaged-target", @@ -289,7 +283,7 @@ describe("terminatePosixOwnedProcessTree", () => { /^\d+\n$/, ); - const mismatch = wrapCommandForLinuxCgroup( + const mismatch = AcpSessionRuntime.wrapCommandForLinuxCgroup( { ...lease, relativePath: "/not-the-current-cgroup" }, "/bin/sh", ["-c", 'printf executed > "$0"', outputPath], @@ -301,7 +295,11 @@ describe("terminatePosixOwnedProcessTree", () => { expect(mismatchResult.status, mismatchResult.stderr).toBe(126); expect(NodeFS.existsSync(outputPath)).toBe(false); - const missingTarget = wrapCommandForLinuxCgroup(lease, "/nonexistent-t3-probe", []); + const missingTarget = AcpSessionRuntime.wrapCommandForLinuxCgroup( + lease, + "/nonexistent-t3-probe", + [], + ); const missingTargetResult = NodeChildProcess.spawnSync( missingTarget.command, missingTarget.args, @@ -319,12 +317,12 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) !== "linux") return; - const controller = makeLinuxCgroupController(); + const controller = AcpSessionRuntime.makeLinuxCgroupController(); const lease = controller.create(); if (lease === undefined) return; - expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe( - lease.relativePath, - ); + expect( + AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")), + ).not.toBe(lease.relativePath); const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); @@ -349,7 +347,7 @@ describe("terminatePosixOwnedProcessTree", () => { "});", "setInterval(() => {}, 1000);", ].join("\n"); - const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [ + const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [ "-e", providerProgram, readyPath, @@ -386,7 +384,7 @@ describe("terminatePosixOwnedProcessTree", () => { } }).pipe(Effect.timeout("2 seconds")); expect(processExists(detachedChildPid)).toBe(true); - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(processExists(sentinel.pid!)).toBe(true); yield* Effect.gen(function* () { while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); @@ -397,11 +395,11 @@ describe("terminatePosixOwnedProcessTree", () => { expect(replacement?.path).not.toBe(lease.path); if (replacement !== undefined) { NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); - yield* terminateLinuxCgroupLease(replacement); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement); expect(NodeFS.existsSync(replacement.path)).toBe(false); } } finally { - yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); try { process.kill(-provider.pid!, "SIGKILL"); } catch { @@ -430,7 +428,7 @@ describe("terminatePosixOwnedProcessTree", () => { let killCalls = 0; let populated = false; let removeCalls = 0; - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => exists, path: "/test/t3-acp-repopulation", @@ -457,11 +455,11 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(killCalls).toBeGreaterThanOrEqual(3); expect(removeCalls).toBe(3); expect(exists).toBe(false); - yield* terminateLinuxCgroupLease(lease); + yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); expect(removeCalls).toBe(3); }), ); @@ -471,7 +469,7 @@ describe("terminatePosixOwnedProcessTree", () => { const removeError = Object.assign(new Error("cgroup remains busy"), { code: "EBUSY", }); - const lease: AcpLinuxCgroupLease = { + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => false, exists: () => true, path: "/test/t3-acp-busy", @@ -483,7 +481,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; - const error = yield* Effect.flip(terminateLinuxCgroupLease(lease)); + const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease)); expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); expect(error.cause).toBe(removeError); }), @@ -493,13 +491,16 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], }); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(100, process.pid, 100, 100, "owned"), parentExecutable: server().executable, parentStartTime: server().startTime, }; - const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot }; - const lease: AcpLinuxCgroupLease = { + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { + captureAttempted: true, + value: ownedRoot, + }; + const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { contains: () => true, exists: () => true, path: "/test/t3-acp-root", @@ -508,19 +509,19 @@ describe("terminatePosixOwnedProcessTree", () => { populated: () => true, remove: () => undefined, }; - signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); expect(fixture.signals).toEqual(["process:100:SIGTERM"]); const reused = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "reused")], }); - signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); expect(reused.signals).toEqual([]); const migrated = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], }); - signalLinuxCgroupRootTerm({ + AcpSessionRuntime.signalLinuxCgroupRootTerm({ controller: migrated.controller, lease: { ...lease, contains: () => false }, root, @@ -565,9 +566,9 @@ describe("terminatePosixOwnedProcessTree", () => { }).pipe(Effect.timeout("2 seconds")); const [workerTid, childPid] = published; expect(workerTid).not.toBe(helper.pid); - expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual( - expect.objectContaining({ pid: childPid, ppid: helper.pid }), - ); + expect( + AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!), + ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid })); } finally { try { process.kill(-helper.pid!, "SIGKILL"); @@ -585,11 +586,11 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100), beforeExec], }); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; - observePosixOwnershipLedger({ + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -598,7 +599,7 @@ describe("terminatePosixOwnedProcessTree", () => { rootPid: 100, }); fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" }); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -610,7 +611,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe( "/usr/bin/bash", ); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -623,13 +624,13 @@ describe("terminatePosixOwnedProcessTree", () => { it("never adopts a replacement PID after initial root capture failed", () => { const fixture = makeController({ processes: [server()] }); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; const capture = () => { try { - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -663,7 +664,7 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100)], }); - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, childPidsOf: (pid) => { childListReads += 1; @@ -678,10 +679,10 @@ describe("terminatePosixOwnedProcessTree", () => { return fixture.controller.snapshot(); }, }; - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const ledger = new Map(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const ledger = new Map(); + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; for (let index = 0; index < 5_000; index += 1) { const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index); ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { @@ -690,7 +691,7 @@ describe("terminatePosixOwnedProcessTree", () => { parentStartTime: "", }); } - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller, frontier, @@ -714,7 +715,7 @@ describe("terminatePosixOwnedProcessTree", () => { ) { childListReads = 0; identityCalls = 0; - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller, frontier, @@ -732,7 +733,7 @@ describe("terminatePosixOwnedProcessTree", () => { ); expect(missing, `missing after ${passes} passes`).toEqual([]); expect(passes).toBeGreaterThan(2); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller, grace: 0, ledger, @@ -751,7 +752,7 @@ describe("terminatePosixOwnedProcessTree", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], }); - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, childPidsOf: (pid) => { childrenCalls += 1; @@ -767,16 +768,16 @@ describe("terminatePosixOwnedProcessTree", () => { }, }; const scope = yield* Scope.make(); - const frontier = new Map(); + const frontier = new Map(); const childQueues = new Map>(); let ledgerValuesCalls = 0; - const ledger = new (class extends Map { - override values(): MapIterator { + const ledger = new (class extends Map { + override values(): MapIterator { ledgerValuesCalls += 1; return super.values(); } })(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; for (let index = 0; index < 5_000; index += 1) { const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { @@ -785,7 +786,7 @@ describe("terminatePosixOwnedProcessTree", () => { parentStartTime: "", }); } - yield* observePosixOwnershipLedgerContinuously({ + yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({ childQueues, controller, frontier, @@ -831,7 +832,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -865,7 +866,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -893,7 +894,7 @@ describe("terminatePosixOwnedProcessTree", () => { }); const result = yield* Effect.exit( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -913,9 +914,13 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("does not treat zombie residual entries as teardown survivors", () => Effect.gen(function* () { - expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true); - expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false); - const withoutState: AcpPosixProcessIdentity = { + expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe( + true, + ); + expect(AcpSessionRuntime.posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe( + false, + ); + const withoutState: AcpSessionRuntime.AcpPosixProcessIdentity = { executable: "/proc/1/exe", pgid: 1, pid: 1, @@ -923,7 +928,7 @@ describe("terminatePosixOwnedProcessTree", () => { sid: 1, startTime: "1", }; - expect(posixProcessIsZombie(withoutState)).toBe(false); + expect(AcpSessionRuntime.posixProcessIsZombie(withoutState)).toBe(false); const root = identity(100, process.pid, 100, 100); const zombieChild = identity(110, 100, 110, 110, "110", "Z"); @@ -936,7 +941,7 @@ describe("terminatePosixOwnedProcessTree", () => { processes.set(pid, { ...current, state: "Z" }); }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -948,19 +953,19 @@ describe("terminatePosixOwnedProcessTree", () => { ); it("admits a live child after a free PID was previously reserved by a dead entry", () => { - const staleChild: AcpOwnedPosixProcess = { + const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(200, 100, 200, 200, "stale-child"), parentExecutable: `/proc/100/exe`, parentStartTime: "100", }; const liveChild = identity(200, 100, 200, 200, "live-child"); const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...root, parentExecutable: `/proc/${process.pid}/exe`, parentStartTime: "server", }; - const ledger = new Map([ + const ledger = new Map([ [`100:${root.startTime}`, ownedRoot], [`200:${staleChild.startTime}`, staleChild], ]); @@ -969,7 +974,7 @@ describe("terminatePosixOwnedProcessTree", () => { }).controller; // First capture: PID 200 is free, so the dead reservation is dropped. - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller, ledger, rootPid: 100, @@ -978,7 +983,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false); // Second capture: a new process reuses PID 200 under the owned parent. - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller: makeController({ processes: [server(), root, liveChild] }).controller, ledger, rootPid: 100, @@ -990,24 +995,24 @@ describe("terminatePosixOwnedProcessTree", () => { }); it("admits a live child when a stale ledger entry still occupies the reused PID", () => { - const staleChild: AcpOwnedPosixProcess = { + const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { ...identity(200, 100, 200, 200, "stale-child"), parentExecutable: `/proc/100/exe`, parentStartTime: "100", }; const liveChild = identity(200, 100, 200, 200, "live-child"); const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpOwnedPosixProcess = { + const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { ...root, parentExecutable: `/proc/${process.pid}/exe`, parentStartTime: "server", }; - const ledger = new Map([ + const ledger = new Map([ [`100:${root.startTime}`, ownedRoot], [`200:${staleChild.startTime}`, staleChild], ]); - capturePosixOwnershipLedger({ + AcpSessionRuntime.capturePosixOwnershipLedger({ controller: makeController({ processes: [server(), root, liveChild] }).controller, ledger, rootPid: 100, @@ -1040,7 +1045,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, rootPid: 100, @@ -1064,7 +1069,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, }); let snapshotCalls = 0; - const controller: AcpPosixProcessTreeController = { + const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { ...fixture.controller, snapshot: () => { snapshotCalls += 1; @@ -1076,7 +1081,7 @@ describe("terminatePosixOwnedProcessTree", () => { }; const error = yield* Effect.flip( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller, discoveryPasses: 2, grace: 0, @@ -1092,10 +1097,10 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () => Effect.gen(function* () { let ignoreSignals = true; - const ledger = new Map(); - const frontier = new Map(); + const ledger = new Map(); + const frontier = new Map(); const childQueues = new Map>(); - const root: AcpPosixOwnershipRoot = { value: undefined }; + const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; const fixture = makeController({ processes: [ server(), @@ -1108,7 +1113,7 @@ describe("terminatePosixOwnedProcessTree", () => { if (!ignoreSignals) processes.delete(pid); }, }); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -1118,7 +1123,7 @@ describe("terminatePosixOwnedProcessTree", () => { }); const explicit = yield* Effect.exit( - terminatePosixOwnedProcessTree({ + AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -1130,7 +1135,7 @@ describe("terminatePosixOwnedProcessTree", () => { expect(fixture.processes.has(111)).toBe(true); fixture.processes.set(112, identity(112, 110, 112, 112)); - observePosixOwnershipLedger({ + AcpSessionRuntime.observePosixOwnershipLedger({ childQueues, controller: fixture.controller, frontier, @@ -1139,7 +1144,7 @@ describe("terminatePosixOwnedProcessTree", () => { rootPid: 100, }); ignoreSignals = false; - yield* terminatePosixOwnedProcessTree({ + yield* AcpSessionRuntime.terminatePosixOwnedProcessTree({ controller: fixture.controller, grace: 0, ledger, @@ -1153,7 +1158,7 @@ describe("terminatePosixOwnedProcessTree", () => { it("fails closed where a stable POSIX identity provider is unavailable", () => { try { - makePosixProcessTreeController("darwin"); + AcpSessionRuntime.makePosixProcessTreeController("darwin"); throw new Error("Expected Darwin process ownership to fail closed"); } catch (error) { expect(error).toMatchObject({ diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index a3aa8005cfb1..d03ce73d09e2 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -22,7 +22,7 @@ import { makeAntigravityStderrHandler, makeAntigravityStdoutTransform, } from "../antigravityAuthSupport.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; export interface AntigravityAcpRuntimeInput extends Omit< diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 0a628eb03c56..cfc7a6adfbe1 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -17,7 +17,10 @@ import { sanitizeAntigravityToolPayload, selectAntigravityPermissionOptionId, } from "./AntigravityProtocol.ts"; -import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; +import { + mergeToolCallState, + parseSessionUpdateEvent, +} from "@t3tools/provider-acp/server/runtimeModel"; const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 77eff09f7e87..3f99288328d4 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as AcpWireSchema from "effect-acp/schema-v1"; -import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; const TOOL_TEXT_LIMIT = 8_000; const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 0039a12683b8..57b4c8fb9d42 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -15,7 +15,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index f4282006f98d..84e5511785b4 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -34,12 +34,12 @@ import { xAiSubagentFinishedNotice, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; -import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { type AcpToolCallState, mergeToolCallState, parseSessionUpdateEvent, -} from "./AcpRuntimeModel.ts"; +} from "@t3tools/provider-acp/server/runtimeModel"; const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 94d278cfa5a9..4c424a664d90 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -11,8 +11,8 @@ import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; -import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; export const xAiRateLimitedErrorCode = -32003; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index ea5aff4b4c77..c4816709a2f6 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { antigravityUserSkillDirectories, resolveAntigravityUserHome, @@ -440,7 +440,7 @@ export function buildAntigravityAcpSpawnInput(input: { readonly auth?: AntigravityAuthConfig; /** Per-process temp directory. Defaults to the profile's shared temp directory. */ readonly runtimeTempDirectory?: string; -}): AcpSpawnInput { +}): AcpSessionRuntime.AcpSpawnInput { return { command: input.installation.executablePath, args: input.profile.platform === "linux" ? ["--uid="] : [], diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 4fab9e92b9f5..454c683896d2 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -20,7 +20,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; import { expect } from "vite-plus/test"; -import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; import { @@ -121,7 +121,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) => Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); state.workspaces.push(cwd); expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]); yield* Effect.addFinalizer(() => diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 2ff9cb659e8b..2c06b082feef 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -13,7 +13,7 @@ import { type AcpError, AcpRequestError } from "effect-acp/errors"; import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; -import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; +import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; const ANTIGRAVITY_TIMEOUT_MS = 180_000; @@ -25,7 +25,7 @@ const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configurat const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); type AntigravityTextRuntime = Pick< - AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"], | "start" | "setMode" | "getConfigOptions" diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index b442053c8001..518e3ea05b23 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -10,7 +10,7 @@ whether a turn runs. This page lists the decisions and evidence a new driver nee - **ACP agents** start as [ACP Registry](../user/providers-acp.md) entries, which follow the ACP spec with no per-agent handling. An agent gets a dedicated driver only when it needs behavior the spec does not cover, and then it is a small flavor over the - [shared ACP adapter](../../apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts), like Grok + [shared ACP adapter](../../packages/provider-acp/src/server/adapter.ts), like Grok and Antigravity. Never add agent-id checks to the generic registry adapter. - **Other protocols** get a native adapter that implements [`ProviderAdapterV2`](../../packages/provider-core/src/server/ProviderAdapter.ts), like Codex, diff --git a/package.json b/package.json index 545a4247c098..f85d50c4a226 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json new file mode 100644 index 000000000000..f3bbfa0dad38 --- /dev/null +++ b/packages/provider-acp/package.json @@ -0,0 +1,65 @@ +{ + "name": "@t3tools/provider-acp", + "private": true, + "type": "module", + "exports": { + "./server/AcpSessionRuntime": { + "types": "./src/server/AcpSessionRuntime.ts", + "import": "./src/server/AcpSessionRuntime.ts" + }, + "./server/adapter": { + "types": "./src/server/adapter.ts", + "import": "./src/server/adapter.ts" + }, + "./server/clientPolicy": { + "types": "./src/server/clientPolicy.ts", + "import": "./src/server/clientPolicy.ts" + }, + "./server/clientTerminals": { + "types": "./src/server/clientTerminals.ts", + "import": "./src/server/clientTerminals.ts" + }, + "./server/coreRuntimeEvents": { + "types": "./src/server/coreRuntimeEvents.ts", + "import": "./src/server/coreRuntimeEvents.ts" + }, + "./server/mcpOverAcpBridge": { + "types": "./src/server/mcpOverAcpBridge.ts", + "import": "./src/server/mcpOverAcpBridge.ts" + }, + "./server/nativeLogging": { + "types": "./src/server/nativeLogging.ts", + "import": "./src/server/nativeLogging.ts" + }, + "./server/runtimeModel": { + "types": "./src/server/runtimeModel.ts", + "import": "./src/server/runtimeModel.ts" + }, + "./server/sessionConfig": { + "types": "./src/server/sessionConfig.ts", + "import": "./src/server/sessionConfig.ts" + }, + "./server/stderr": { + "types": "./src/server/stderr.ts", + "import": "./src/server/stderr.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "diff": "8.0.3", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts similarity index 99% rename from apps/server/src/provider/acp/AcpSessionRuntime.ts rename to packages/provider-acp/src/server/AcpSessionRuntime.ts index 9c2cb395f307..8b060291f8b8 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -30,7 +30,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; -import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./AcpStderr.ts"; +import { appendAcpStderrTail, sanitizeAcpStderrExcerpt } from "./stderr.ts"; import { collectSessionConfigOptionValues, decideToolCallUpdateEmission, @@ -47,7 +47,7 @@ import { type AcpParsedSessionEvent, type AcpSessionModeState, type AcpToolCallState, -} from "./AcpRuntimeModel.ts"; +} from "./runtimeModel.ts"; const MAX_SHOWN_TOOL_CALL_IDS = 256; @@ -1329,7 +1329,7 @@ export class AcpSessionRuntime extends Context.Service< payload: unknown, ) => Effect.Effect; } ->()("t3/provider/acp/AcpSessionRuntime") { +>()("@t3tools/provider-acp/server/AcpSessionRuntime") { static layer( options: AcpSessionRuntimeOptions, ): Layer.Layer< diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/packages/provider-acp/src/server/adapter.ts similarity index 99% rename from apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts rename to packages/provider-acp/src/server/adapter.ts index 03a5c629b663..03d168c04a5b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -57,12 +57,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { - makeAcpMcpOverAcpBridge, - type AcpMcpOverAcpBridge, -} from "../../mcp/AcpMcpOverAcpBridge.ts"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { @@ -78,7 +74,7 @@ import { type AcpAgentTerminalState, type AcpSessionModeState, type AcpToolCallState, -} from "../../provider/acp/AcpRuntimeModel.ts"; +} from "./runtimeModel.ts"; import { acpClientExecuteDisposition, acpMcpToolApprovalElicitationDisposition, @@ -86,14 +82,14 @@ import { type AcpPermissionDisposition, makeAcpClientPolicyGrants, unknownRecord, -} from "../../provider/acp/AcpClientPolicy.ts"; +} from "./clientPolicy.ts"; import { makeAcpClientTerminals, resolveEmbeddedTerminalContent, type AcpClientTerminals, -} from "../../provider/acp/AcpClientTerminals.ts"; -import { ACP_SESSION_MODE_OPTION_ID } from "../../provider/acp/AcpSessionConfig.ts"; -import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +} from "./clientTerminals.ts"; +import { ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { t3AcpPromptWithInstructions, type T3AcpInstructionState, @@ -487,7 +483,7 @@ export interface AcpAdapterV2Options { readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; + readonly host: ProviderHost.ProviderHostShape; /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ readonly selfInvocation: SelfInvocation; /** @@ -1497,7 +1493,7 @@ function shouldPersistToolUpdate( export function makeAcpAdapterV2( options: AcpAdapterV2Options, ): ProviderAdapter.ProviderAdapterV2Shape { - const { flavor, fileSystem, idAllocator, serverConfig, selfInvocation: self } = options; + const { flavor, fileSystem, idAllocator, host, selfInvocation: self } = options; const driver = flavor.driver; const continuationRequests = options.continuationRequests; const postSettleContinuationEnabled = @@ -6781,8 +6777,7 @@ export function makeAcpAdapterV2( const messageText = providerMessageTextWithAttachmentPaths({ text: turnInput.message.text, attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + resolveAttachmentPath: host.resolveAttachmentPath, }); const text = t3AcpPromptWithInstructions({ prompt: messageText, @@ -6804,10 +6799,7 @@ export function makeAcpAdapterV2( }); } for (const attachment of imageAttachments) { - const path = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment: attachment as ChatAttachment, - }); + const path = host.resolveAttachmentPath(attachment as ChatAttachment); if (path === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver, diff --git a/apps/server/src/provider/acp/AcpClientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts similarity index 99% rename from apps/server/src/provider/acp/AcpClientPolicy.test.ts rename to packages/provider-acp/src/server/clientPolicy.test.ts index 6ee271627934..fd2a9714c57c 100644 --- a/apps/server/src/provider/acp/AcpClientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -13,7 +13,7 @@ import { acpPermissionDisposition, makeAcpClientPolicyGrants, type AcpRuntimePolicy, -} from "./AcpClientPolicy.ts"; +} from "./clientPolicy.ts"; function permissionRequest( kind: NonNullable, diff --git a/apps/server/src/provider/acp/AcpClientPolicy.ts b/packages/provider-acp/src/server/clientPolicy.ts similarity index 100% rename from apps/server/src/provider/acp/AcpClientPolicy.ts rename to packages/provider-acp/src/server/clientPolicy.ts diff --git a/apps/server/src/provider/acp/AcpClientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts similarity index 99% rename from apps/server/src/provider/acp/AcpClientTerminals.test.ts rename to packages/provider-acp/src/server/clientTerminals.test.ts index c923a996898f..83b5273a21ad 100644 --- a/apps/server/src/provider/acp/AcpClientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -8,7 +8,7 @@ import { makeAcpClientTerminals, resolveEmbeddedTerminalContent, type AcpClientTerminals, -} from "./AcpClientTerminals.ts"; +} from "./clientTerminals.ts"; const withTerminals = (use: (terminals: AcpClientTerminals) => Effect.Effect) => Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpClientTerminals.ts b/packages/provider-acp/src/server/clientTerminals.ts similarity index 100% rename from apps/server/src/provider/acp/AcpClientTerminals.ts rename to packages/provider-acp/src/server/clientTerminals.ts diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/packages/provider-acp/src/server/coreRuntimeEvents.test.ts similarity index 99% rename from apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts rename to packages/provider-acp/src/server/coreRuntimeEvents.test.ts index 65c133012fb0..dc4f5ea70605 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/packages/provider-acp/src/server/coreRuntimeEvents.test.ts @@ -8,7 +8,7 @@ import { makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, -} from "./AcpCoreRuntimeEvents.ts"; +} from "./coreRuntimeEvents.ts"; describe("AcpCoreRuntimeEvents", () => { it("maps ACP permission requests to canonical runtime events", () => { diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/packages/provider-acp/src/server/coreRuntimeEvents.ts similarity index 99% rename from apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts rename to packages/provider-acp/src/server/coreRuntimeEvents.ts index c3fa348d2763..3218e23cd88c 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/packages/provider-acp/src/server/coreRuntimeEvents.ts @@ -17,7 +17,7 @@ import { type AcpPlanUpdate, type AcpToolCallState, canonicalItemTypeFromAcpToolKind, -} from "./AcpRuntimeModel.ts"; +} from "./runtimeModel.ts"; type AcpAdapterRawSource = Extract< RuntimeEventRawSource, diff --git a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts similarity index 98% rename from apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts rename to packages/provider-acp/src/server/mcpOverAcpBridge.test.ts index a564a711a2f6..c9d7b2bbf234 100644 --- a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { makeAcpMcpOverAcpBridge } from "./AcpMcpOverAcpBridge.ts"; +import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; describe("AcpMcpOverAcpBridge", () => { it.effect("forwards authenticated MCP requests and closes the negotiated session", () => diff --git a/apps/server/src/mcp/AcpMcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts similarity index 98% rename from apps/server/src/mcp/AcpMcpOverAcpBridge.ts rename to packages/provider-acp/src/server/mcpOverAcpBridge.ts index a5185e2515ec..831bde8756be 100644 --- a/apps/server/src/mcp/AcpMcpOverAcpBridge.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -5,7 +5,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type * as AcpSchema from "effect-acp/compat"; -import { responsePayloads } from "./AcpMcpStdioBridge.ts"; +import { responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; const MAX_CONNECTIONS = 16; const MAX_MESSAGE_BYTES = 8 * 1024 * 1024; @@ -133,7 +133,7 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun new AcpMcpOverAcpError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - const payloads = [...(yield* Stream.runCollect(responsePayloads(response)))]; + const payloads = [...(yield* Stream.runCollect(responsePayloads(response, bridgeError)))]; for (const payload of payloads) { connection.protocolVersion = protocolVersionOf(payload) ?? connection.protocolVersion; } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/packages/provider-acp/src/server/nativeLogging.test.ts similarity index 97% rename from apps/server/src/provider/acp/AcpNativeLogging.test.ts rename to packages/provider-acp/src/server/nativeLogging.test.ts index 54b485613f37..3872121bc6d7 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/packages/provider-acp/src/server/nativeLogging.test.ts @@ -8,8 +8,8 @@ import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as AcpErrors from "effect-acp/errors"; -import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; -import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { makeAcpNativeLoggerFactory } from "./nativeLogging.ts"; const nodeServicesIt = it.layer(NodeServices.layer); const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); @@ -18,7 +18,7 @@ nodeServicesIt("ACP native logging", (it) => { it.effect("records bounded request and protocol diagnostics without raw payloads", () => Effect.gen(function* () { const records: Array = []; - const nativeEventLogger: EventNdjsonLogger = { + const nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger = { filePath: "/tmp/provider-native.ndjson", write: (event) => Effect.sync(() => void records.push(event)), close: () => Effect.void, diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/packages/provider-acp/src/server/nativeLogging.ts similarity index 97% rename from apps/server/src/provider/acp/AcpNativeLogging.ts rename to packages/provider-acp/src/server/nativeLogging.ts index a510c5f88b5a..b869efe47259 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/packages/provider-acp/src/server/nativeLogging.ts @@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { structuralProtocolMethod, summarizeNativeProtocolPayload, @@ -121,7 +121,7 @@ function filterTransientProtocolLog( export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; readonly verboseProtocolLogging?: boolean; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts similarity index 99% rename from apps/server/src/provider/acp/AcpRuntimeModel.test.ts rename to packages/provider-acp/src/server/runtimeModel.test.ts index 27cb3234b375..dab23a53fead 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -19,7 +19,7 @@ import { syntheticLoadSessionResponseFromInitialize, toolCallProgressLength, type AcpToolCallState, -} from "./AcpRuntimeModel.ts"; +} from "./runtimeModel.ts"; describe("AcpRuntimeModel", () => { it("parses session mode state from typed ACP session setup responses", () => { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts similarity index 100% rename from apps/server/src/provider/acp/AcpRuntimeModel.ts rename to packages/provider-acp/src/server/runtimeModel.ts diff --git a/apps/server/src/provider/acp/AcpSessionConfig.test.ts b/packages/provider-acp/src/server/sessionConfig.test.ts similarity index 99% rename from apps/server/src/provider/acp/AcpSessionConfig.test.ts rename to packages/provider-acp/src/server/sessionConfig.test.ts index 8400605be264..8e208e88aa5c 100644 --- a/apps/server/src/provider/acp/AcpSessionConfig.test.ts +++ b/packages/provider-acp/src/server/sessionConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./AcpSessionConfig.ts"; +import { ACP_SESSION_MODE_OPTION_ID, acpProviderOptionDescriptors } from "./sessionConfig.ts"; describe("acpProviderOptionDescriptors", () => { it("maps non-model select options and excludes model and collaboration categories", () => { diff --git a/apps/server/src/provider/acp/AcpSessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts similarity index 98% rename from apps/server/src/provider/acp/AcpSessionConfig.ts rename to packages/provider-acp/src/server/sessionConfig.ts index 6454eb4444c9..0d2a821762e5 100644 --- a/apps/server/src/provider/acp/AcpSessionConfig.ts +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -1,7 +1,7 @@ import type { ProviderOptionChoice, ProviderOptionDescriptor } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/compat"; -import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; +import type { AcpSessionModeState } from "./runtimeModel.ts"; /** * Maps ACP session configuration and session modes onto T3's provider option diff --git a/apps/server/src/provider/acp/AcpStderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts similarity index 98% rename from apps/server/src/provider/acp/AcpStderr.test.ts rename to packages/provider-acp/src/server/stderr.test.ts index e88b59bdb0e1..f5f704f04996 100644 --- a/apps/server/src/provider/acp/AcpStderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -4,7 +4,7 @@ import { ACP_STDERR_TAIL_MAX_CHARS, appendAcpStderrTail, sanitizeAcpStderrExcerpt, -} from "./AcpStderr.ts"; +} from "./stderr.ts"; describe("AcpStderr", () => { it("keeps a bounded tail of stderr chunks", () => { diff --git a/apps/server/src/provider/acp/AcpStderr.ts b/packages/provider-acp/src/server/stderr.ts similarity index 100% rename from apps/server/src/provider/acp/AcpStderr.ts rename to packages/provider-acp/src/server/stderr.ts diff --git a/packages/provider-acp/tsconfig.json b/packages/provider-acp/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-acp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 318037cdcb3b..a8509b01e130 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -32,6 +32,8 @@ export interface ProviderHostPaths { readonly stateDir: string; /** Scratch space for provider probes and generated helper files. */ readonly providerStatusCacheDir: string; + /** Where chat attachments are stored; agents that sandbox file reads must be granted it. */ + readonly attachmentsDir: string; } /** One opaque credential blob in the server's secret store. Only the provider decodes it. */ diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 6d9e3827531b..76f7fda46954 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -109,6 +109,7 @@ function layerProviderHost(input: { baseDir: "/t3", stateDir: "/t3/userdata", providerStatusCacheDir: "/t3/caches", + attachmentsDir: "/t3/userdata/attachments", }, settings: input.settings ?? { get: Effect.succeed(DEFAULT_SERVER_SETTINGS), diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index eb1d07af6c86..243664857910 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -42,7 +42,13 @@ export const layerTestProviderHost = ( const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; const credentials = new Map(); return ProviderHost.ProviderHost.of({ - paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, + paths: { + cwd: options.cwd ?? process.cwd(), + baseDir, + stateDir, + providerStatusCacheDir, + attachmentsDir, + }, settings: { get: Effect.succeed(settings), changes: Stream.empty, diff --git a/packages/shared/package.json b/packages/shared/package.json index c5bca3054290..4942d700cadd 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -466,6 +466,10 @@ "./atomicWrite": { "types": "./src/atomicWrite.ts", "import": "./src/atomicWrite.ts" + }, + "./mcpResponsePayloads": { + "types": "./src/mcpResponsePayloads.ts", + "import": "./src/mcpResponsePayloads.ts" } }, "scripts": { diff --git a/packages/shared/src/mcpResponsePayloads.ts b/packages/shared/src/mcpResponsePayloads.ts new file mode 100644 index 000000000000..d46ef8e73fe9 --- /dev/null +++ b/packages/shared/src/mcpResponsePayloads.ts @@ -0,0 +1,65 @@ +// MCP responses carry opaque JSON-RPC that callers relay verbatim. +// @effect-diagnostics preferSchemaOverJson:off +/** + * Reads the JSON-RPC payloads of a response from an MCP streamable-HTTP + * endpoint. Used on the ACP bridge fast path, so it imports only effect core. + * + * @module mcpResponsePayloads + */ +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +async function* sseDataLines(response: Response): AsyncGenerator { + if (response.body === null) return; + const decoder = new TextDecoder(); + let buffered = ""; + for await (const chunk of response.body) { + buffered += decoder.decode(chunk as Uint8Array, { stream: true }); + let separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); + while (separatorIndex !== -1) { + const rawEvent = buffered.slice(0, separatorIndex); + buffered = buffered.slice(separatorIndex).replace(/^(?:\r?\n){2}/u, ""); + const data = rawEvent + .split(/\r?\n/u) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n"); + if (data.length > 0) yield data; + separatorIndex = buffered.search(/\n\n|\r\n\r\n/u); + } + } +} + +export const discardResponseBody = (response: Response): Effect.Effect => + Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); + +/** + * Every JSON-RPC payload carried by a response, in arrival order: nothing for + * notification acknowledgements, each SSE `data:` event as it streams in, or + * the single JSON body. + */ +export function responsePayloads( + response: Response, + onError: (cause: unknown) => E, +): Stream.Stream { + if (response.status === 202 || response.status === 204) { + return Stream.unwrap(discardResponseBody(response).pipe(Effect.as(Stream.empty))); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream")) { + return Stream.fromAsyncIterable(sseDataLines(response), onError).pipe( + Stream.mapEffect((data) => Effect.try({ try: () => JSON.parse(data), catch: onError })), + ); + } + return Stream.unwrap( + Effect.tryPromise({ try: () => response.text(), catch: onError }).pipe( + Effect.flatMap((text) => + text.trim().length === 0 + ? Effect.succeed(Stream.empty) + : Effect.try({ try: () => JSON.parse(text) as unknown, catch: onError }).pipe( + Effect.map((payload) => Stream.fromIterable([payload])), + ), + ), + ), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e33ad5cabe..d0b0d970c12b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -600,6 +600,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../../packages/provider-acp '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -1092,6 +1095,40 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-acp: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + diff: + specifier: 8.0.3 + version: 8.0.3 + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 6c5a1707854f..40dfbc7c8d3c 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-acp/package.json", "packages/provider-cursor/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", From b707eeb052782cfd8b1ff0445f84b2aaf01da38b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 18:30:58 -0700 Subject: [PATCH 048/377] refactor(provider-grok): move Grok into its own provider package (#17357) Co-authored-by: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/components/ProviderIcon.tsx | 16 ------- apps/mobile/src/lib/providerClients.ts | 2 + apps/server/package.json | 1 + .../scripts/record-grok-acp-replay-fixture.ts | 7 +-- .../Adapters/AcpAdapterV2.test.ts | 6 +-- .../Adapters/GrokAdapterV2.test.ts | 8 ++-- .../Adapters/GrokAdapterV2.testkit.ts | 15 +++--- .../orchestration-v2/CommandPolicy.test.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- apps/server/src/provider/GrokProvider.test.ts | 6 +-- .../provider/ProviderInstanceRegistry.test.ts | 4 +- apps/server/src/provider/builtInDrivers.ts | 2 +- .../textGeneration/GrokTextGeneration.test.ts | 5 +- apps/web/package.json | 1 + apps/web/src/components/Icons.tsx | 12 ----- .../components/chat/ProviderInstanceIcon.tsx | 4 +- .../components/settings/providerDriverMeta.ts | 8 +--- docs/internals/adding-a-provider.md | 2 +- docs/internals/providers.md | 2 +- package.json | 2 +- packages/contracts/src/settings.ts | 26 ----------- packages/provider-grok/package.json | 42 +++++++++++++++++ packages/provider-grok/src/client.ts | 28 +++++++++++ packages/provider-grok/src/server.ts | 7 +++ .../src/server/acpCliProbe.test.ts | 2 +- .../src/server/acpSupport.test.ts | 2 +- .../provider-grok/src/server/acpSupport.ts | 4 +- .../provider-grok/src/server/adapter.ts | 10 ++-- .../provider-grok/src/server/driver.test.ts | 24 ++-------- .../provider-grok/src/server/driver.ts | 32 ++++++------- .../provider-grok/src/server/skills.test.ts | 2 +- .../provider-grok/src/server/skills.ts | 3 +- .../provider-grok/src/server/status.ts | 6 +-- .../src/server/textGeneration.ts | 5 +- .../provider-grok/src/server/usageLimits.ts | 0 .../src/server/xaiAcpExtension.test.ts | 2 +- .../src/server/xaiAcpExtension.ts | 0 .../src/server/xaiBackgroundTasks.test.ts | 2 +- .../src/server/xaiBackgroundTasks.ts | 0 packages/provider-grok/src/settings.ts | 39 ++++++++++++++++ packages/provider-grok/src/testing.ts | 38 +++++++++++++++ packages/provider-grok/tsconfig.json | 7 +++ pnpm-lock.yaml | 46 +++++++++++++++++++ scripts/release-smoke.ts | 1 + 45 files changed, 287 insertions(+), 149 deletions(-) create mode 100644 packages/provider-grok/package.json create mode 100644 packages/provider-grok/src/client.ts create mode 100644 packages/provider-grok/src/server.ts rename apps/server/src/provider/acp/GrokAcpCliProbe.test.ts => packages/provider-grok/src/server/acpCliProbe.test.ts (98%) rename apps/server/src/provider/acp/GrokAcpSupport.test.ts => packages/provider-grok/src/server/acpSupport.test.ts (99%) rename apps/server/src/provider/acp/GrokAcpSupport.ts => packages/provider-grok/src/server/acpSupport.ts (98%) rename apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts => packages/provider-grok/src/server/adapter.ts (98%) rename apps/server/src/provider/Drivers/GrokDriver.test.ts => packages/provider-grok/src/server/driver.test.ts (80%) rename apps/server/src/provider/Drivers/GrokDriver.ts => packages/provider-grok/src/server/driver.ts (90%) rename apps/server/src/provider/Drivers/GrokSkills.test.ts => packages/provider-grok/src/server/skills.test.ts (99%) rename apps/server/src/provider/Drivers/GrokSkills.ts => packages/provider-grok/src/server/skills.ts (97%) rename apps/server/src/provider/GrokProvider.ts => packages/provider-grok/src/server/status.ts (99%) rename apps/server/src/textGeneration/GrokTextGeneration.ts => packages/provider-grok/src/server/textGeneration.ts (97%) rename apps/server/src/provider/grokUsageLimits.ts => packages/provider-grok/src/server/usageLimits.ts (100%) rename apps/server/src/provider/acp/XAiAcpExtension.test.ts => packages/provider-grok/src/server/xaiAcpExtension.test.ts (99%) rename apps/server/src/provider/acp/XAiAcpExtension.ts => packages/provider-grok/src/server/xaiAcpExtension.ts (100%) rename apps/server/src/provider/acp/XAiBackgroundTasks.test.ts => packages/provider-grok/src/server/xaiBackgroundTasks.test.ts (99%) rename apps/server/src/provider/acp/XAiBackgroundTasks.ts => packages/provider-grok/src/server/xaiBackgroundTasks.ts (100%) create mode 100644 packages/provider-grok/src/settings.ts create mode 100644 packages/provider-grok/src/testing.ts create mode 100644 packages/provider-grok/tsconfig.json diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2b73304bebd1..97a0cd33ac74 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -71,6 +71,7 @@ "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", + "@t3tools/provider-grok": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index f2eb4e3c7f70..34141a3121d3 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -93,22 +93,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "grok") { - const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; - return ( - - - - - ); - } - const packageIcon = getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index cb469a28706c..d434e6bde328 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,6 +1,7 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; +import { grokClient } from "@t3tools/provider-grok/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -8,6 +9,7 @@ import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ const providerClients = makeProviderClientRegistry([ cursorClient, + grokClient, museClient, openCodeClient, piClient, diff --git a/apps/server/package.json b/apps/server/package.json index 5e731012c583..cb0f2dfcdc13 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -41,6 +41,7 @@ "@t3tools/provider-acp": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", + "@t3tools/provider-grok": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index 30db5e0ef85a..d7c7da2c8e08 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -10,7 +10,8 @@ * node scripts/record-grok-acp-replay-fixture.ts --scenario simple */ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { GrokSettings, type ProviderReplayEntry } from "@t3tools/contracts"; +import { type ProviderReplayEntry } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; @@ -31,7 +32,7 @@ import { GROK_PROVIDER, grokLaunchRuntimeMode, makeGrokAdapterV2, -} from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts"; +} from "@t3tools/provider-grok/testing"; import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; @@ -43,7 +44,7 @@ import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixture import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; -import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; +import { makeGrokAcpRuntime } from "@t3tools/provider-grok/testing"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; const wallClock = Clock.Clock.defaultValue(); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 5e225d570d7a..62bff5add8ef 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -12,7 +12,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { CheckpointId, - GrokSettings, EnvironmentId, MessageId, type ModelSelection, @@ -27,6 +26,7 @@ import { ThreadId, type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as DateTime from "effect/DateTime"; @@ -64,7 +64,7 @@ import { makeXAiPromptCompletionRuntime, normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, -} from "../../provider/acp/XAiAcpExtension.ts"; +} from "@t3tools/provider-grok/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterProtocolError, @@ -93,7 +93,7 @@ import { type AcpAdapterV2SubagentUpdate, } from "@t3tools/provider-acp/server/adapter"; -import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; +import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing"; import { acpRegistryPromptFailure, registerMistralVibeAcpExtensions, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 23ebb1b4f2a1..1dcb231ea103 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -1,16 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { - GrokSettings, ProjectId, ProviderInstanceId, ProviderSessionId, type RuntimeMode, ThreadId, } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as EffectAcpErrors from "effect-acp/errors"; -import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts"; +import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing"; import { assert, describe, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -25,7 +25,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProjectStore from "../ProjectStore.ts"; -import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; +import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -43,7 +43,7 @@ import { makeGrokAdapterV2, GrokProviderCapabilitiesV2, type GrokAdapterV2Options, -} from "./GrokAdapterV2.ts"; +} from "@t3tools/provider-grok/testing"; const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({ binaryPath: "grok-launch-test", diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 58446793decd..172150432f53 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { GrokSettings } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; @@ -12,11 +12,8 @@ import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; -import { - GROK_ACP_CANCEL_META, - GROK_ACP_INITIALIZE_META, -} from "../../provider/acp/GrokAcpSupport.ts"; -import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts"; +import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META } from "@t3tools/provider-grok/testing"; +import { makeXAiPromptCompletionRuntime } from "@t3tools/provider-grok/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; @@ -29,7 +26,11 @@ import { makeAcpReplayCompletenessAssertion, makeAcpReplayRuntime, } from "./AcpAdapterV2.testkit.ts"; -import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; +import { + GROK_DEFAULT_INSTANCE_ID, + GROK_PROVIDER, + makeGrokAdapterV2, +} from "@t3tools/provider-grok/testing"; const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({}); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 517229c222ea..8df7da5cacdc 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -13,7 +13,7 @@ import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; -import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; +import { GrokProviderCapabilitiesV2 } from "@t3tools/provider-grok/testing"; import * as CommandPolicy from "./CommandPolicy.ts"; const commandId = CommandId.make("command-policy-test"); diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index e48ceb60177c..f1ce18c12f88 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -13,7 +13,7 @@ import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv, } from "@t3tools/provider-cursor/server"; -import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; +import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 000176e9bb23..73f56a3558db 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { GrokSettings } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import { buildGrokModelCapabilities, @@ -17,9 +17,9 @@ import { checkGrokProviderStatus, grokSlashCommandsFromInitialize, parseGrokModelsCliOutput, -} from "./GrokProvider.ts"; +} from "@t3tools/provider-grok/testing"; import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; -import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; +import { grokUsageResponseToLimits, readGrokAccount } from "@t3tools/provider-grok/testing"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 353c29fb90da..933751bdd39b 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -31,11 +31,11 @@ import { EnvironmentId, type ClaudeSettings, type CodexSettings, - type GrokSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import type { GrokSettings } from "@t3tools/provider-grok/settings"; import type { CursorSettings } from "@t3tools/provider-cursor/settings"; import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; @@ -54,7 +54,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; import * as ModelManifest from "./ModelManifest.ts"; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 516e52d0e49b..1e21b8dc7cc1 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,7 +25,7 @@ import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/Antigrav import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 8165683f62cf..80f5c236259d 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -11,11 +11,12 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; -import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { GrokSettings } from "@t3tools/provider-grok/settings"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; -import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; +import { makeGrokTextGeneration } from "@t3tools/provider-grok/testing"; import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); diff --git a/apps/web/package.json b/apps/web/package.json index 64012d9bdab3..5e9c5e1e9785 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", + "@t3tools/provider-grok": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index be008bac3330..3e4d53d55b63 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -364,18 +364,6 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); -export const GrokIcon: Icon = ({ className, ...props }) => ( - - - - -); - export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 927f78ceb4f2..13322e19bef8 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react"; import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons"; +import { AntigravityIcon, ClaudeAI, Icon, OpenAI } from "../Icons"; import { cn } from "~/lib/utils"; import { providerClients } from "../settings/providerDriverMeta"; @@ -17,14 +17,12 @@ import { const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, - [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: "text-black dark:text-white", [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", - [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index aca6ffbc724f..e18a271f9278 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,11 @@ import { AntigravitySettings, ClaudeSettings, CodexSettings, - GrokSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; +import { grokClient } from "@t3tools/provider-grok/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -25,11 +25,7 @@ export const providerClients = makeProviderClientRegistry([ settingsSchema: ClaudeSettings, }, cursorClient, - { - driverKind: ProviderDriverKind.make("grok"), - label: "Grok", - settingsSchema: GrokSettings, - }, + grokClient, openCodeClient, { driverKind: ProviderDriverKind.make("antigravity"), diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 518e3ea05b23..3df65eaf047a 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -26,7 +26,7 @@ capabilities, never the driver kind. falls back, for example to portable context handoff for forks. A capability left on that fails at runtime is a bug. - **Permission modes.** Offer only modes the provider enforces natively, through - `supportedRuntimeModes` in the provider presentation ([Grok](../../apps/server/src/provider/GrokProvider.ts) + `supportedRuntimeModes` in the provider presentation ([Grok](../../packages/provider-grok/src/server/status.ts) and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The server runs an unoffered stored mode as Supervised diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 6de371a95084..5f9642c2db27 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r ## Setup must not happen as a health-check side effect Opening a provider session can start MCP servers, run hooks, or launch a login browser. -[Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and +[Grok probes](../../packages/provider-grok/src/server/status.ts) avoid authentication and session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for explicit setup or model refresh; background checks use initialization only. diff --git a/package.json b/package.json index f85d50c4a226..df724bd09a28 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c43df190af3f..bd8af77a4ea1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema( ); export type ClaudeSettings = typeof ClaudeSettings.Type; -export const GrokSettings = makeProviderSettingsSchema( - { - // Off by default (like Cursor and OpenCode): the binding is not yet - // stable enough to probe on every install. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("grok").pipe( - Schema.annotateKey({ - title: "Binary path", - description: "Path to the Grok CLI binary.", - providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["binaryPath"], - }, -); -export type GrokSettings = typeof GrokSettings.Type; - /** * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in * in the browser. The API key and Agent Platform methods take credentials from diff --git a/packages/provider-grok/package.json b/packages/provider-grok/package.json new file mode 100644 index 000000000000..f72b76a11dfe --- /dev/null +++ b/packages/provider-grok/package.json @@ -0,0 +1,42 @@ +{ + "name": "@t3tools/provider-grok", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-grok/src/client.ts b/packages/provider-grok/src/client.ts new file mode 100644 index 000000000000..d2ff95424f8c --- /dev/null +++ b/packages/provider-grok/src/client.ts @@ -0,0 +1,28 @@ +/** + * Grok's client definition: settings form, label, and glyph. + * Browser- and React Native-safe. + * + * @module provider-grok/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { GrokSettings } from "./settings.ts"; + +export const grokClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("grok"), + label: "Grok", + settingsSchema: GrokSettings, + icon: { + viewBox: "0 0 24 24", + fill: { light: "#0F0F0F", dark: "#F5F5F5" }, + paths: [ + { + d: "M9.26905 15.284L17.2479 9.36086C17.6391 9.07047 18.1981 9.18374 18.3845 9.63478C19.3655 12.0135 18.9272 14.8721 16.9755 16.8349C15.0238 18.7976 12.3082 19.228 9.8261 18.2477L7.1146 19.5102C11.0037 22.1834 15.7263 21.5223 18.6774 18.5525C21.0182 16.1985 21.7432 12.9897 21.0653 10.0961L21.0714 10.1023C20.0884 5.85143 21.3131 4.15233 23.8218 0.677913C23.8812 0.595532 23.9406 0.513151 24 0.428711L20.6987 3.74866V3.73836L9.267 15.2861", + }, + { + d: "M7.62249 16.7237C4.83113 14.0422 5.3124 9.89222 7.69417 7.49905C9.45541 5.72786 12.341 5.00497 14.86 6.06768L17.5653 4.81138C17.0779 4.45714 16.4533 4.07613 15.7365 3.80839C12.4966 2.46764 8.6178 3.13492 5.98413 5.78141C3.45081 8.32904 2.65415 12.2463 4.02219 15.5889C5.04412 18.0871 3.36889 19.8541 1.68137 21.6377C1.08337 22.2699 0.483318 22.9022 0 23.5716L7.62045 16.7257", + }, + ], + }, +}); diff --git a/packages/provider-grok/src/server.ts b/packages/provider-grok/src/server.ts new file mode 100644 index 000000000000..4fe180316a84 --- /dev/null +++ b/packages/provider-grok/src/server.ts @@ -0,0 +1,7 @@ +/** + * Grok's server entry: the driver and adapter driver the server registers. + * + * @module provider-grok/server + */ +export { GrokDriver, type GrokDriverEnv } from "./server/driver.ts"; +export { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/packages/provider-grok/src/server/acpCliProbe.test.ts similarity index 98% rename from apps/server/src/provider/acp/GrokAcpCliProbe.test.ts rename to packages/provider-grok/src/server/acpCliProbe.test.ts index b5c8177d61f0..aa1a77046758 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/packages/provider-grok/src/server/acpCliProbe.test.ts @@ -18,7 +18,7 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import { describe, expect } from "vite-plus/test"; -import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; +import { makeGrokAcpRuntime } from "./acpSupport.ts"; const makeProbeRuntime = Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/packages/provider-grok/src/server/acpSupport.test.ts similarity index 99% rename from apps/server/src/provider/acp/GrokAcpSupport.test.ts rename to packages/provider-grok/src/server/acpSupport.test.ts index d15b12fbc2da..e49f29566a2b 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/packages/provider-grok/src/server/acpSupport.test.ts @@ -10,7 +10,7 @@ import { grokAcpSpawnArgs, isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, -} from "./GrokAcpSupport.ts"; +} from "./acpSupport.ts"; describe("grokAcpRuntimeProcessOwnership", () => { it("opts Grok into detached process-tree ownership on the injected host platform", () => { diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/packages/provider-grok/src/server/acpSupport.ts similarity index 98% rename from apps/server/src/provider/acp/GrokAcpSupport.ts rename to packages/provider-grok/src/server/acpSupport.ts index 57b4c8fb9d42..e30b475219ef 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/packages/provider-grok/src/server/acpSupport.ts @@ -1,11 +1,11 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { - type GrokSettings, type ProviderApprovalOption, ProviderDriverKind, type RuntimeMode, } from "@t3tools/contracts"; +import type { GrokSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,7 +16,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; +import { makeXAiPromptCompletionRuntime } from "./xaiAcpExtension.ts"; const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/packages/provider-grok/src/server/adapter.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts rename to packages/provider-grok/src/server/adapter.ts index e44c2da421a9..757e27bceef5 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -3,16 +3,16 @@ import { XAiPromptFailureText, isXAiTaskCompletedWakeNotification, xAiRateLimitedErrorCode, -} from "../../provider/acp/XAiAcpExtension.ts"; +} from "./xaiAcpExtension.ts"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; import { defaultInstanceIdForDriver, - GrokSettings, ProviderDriverKind, type OrchestrationV2ProviderCapabilities, type RuntimeMode, } from "@t3tools/contracts"; +import { GrokSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -31,7 +31,7 @@ import { grokApprovalOptions, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, -} from "../../provider/acp/GrokAcpSupport.ts"; +} from "./acpSupport.ts"; import { extractGrokPlanMarkdownFromToolCallData, extractXAiAcpBackgroundToolMutation, @@ -52,11 +52,11 @@ import { registerXAiBackgroundTaskTracking, XAiAskUserQuestionRequest, XAiExitPlanModeRequest, -} from "../../provider/acp/XAiAcpExtension.ts"; +} from "./xaiAcpExtension.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/packages/provider-grok/src/server/driver.test.ts similarity index 80% rename from apps/server/src/provider/Drivers/GrokDriver.test.ts rename to packages/provider-grok/src/server/driver.test.ts index e0da3203902c..1885a11d22e6 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -9,27 +9,15 @@ import * as Path from "effect/Path"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { GrokDriver } from "./GrokDriver.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { GrokDriver } from "./driver.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderHostLive from "../ProviderHostLive.ts"; -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -const layerDeps = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-grok-driver-update-", -}).pipe( +const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(IdAllocator.layer), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -43,10 +31,6 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { ), ), ); -const layerTest = ProviderHostLive.layer.pipe( - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(layerDeps), -); const noSpawner = ChildProcessSpawner.make(() => Effect.die("Disabled Grok must not spawn a process"), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/packages/provider-grok/src/server/driver.ts similarity index 90% rename from apps/server/src/provider/Drivers/GrokDriver.ts rename to packages/provider-grok/src/server/driver.ts index f7226d5f1e9f..e3201d0f03c3 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -1,4 +1,5 @@ -import { GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { GrokSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,21 +8,17 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; -import { - GrokAdapterV2Driver, - type GrokAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/GrokAdapterV2.ts"; -import { ProviderDriverError } from "../Errors.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { makeGrokTextGeneration } from "./textGeneration.ts"; +import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus, enrichGrokSnapshot, -} from "../GrokProvider.ts"; -import { readGrokAccount } from "../grokUsageLimits.ts"; +} from "./status.ts"; +import { readGrokAccount } from "./usageLimits.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { defaultProviderContinuationIdentity, @@ -30,7 +27,7 @@ import { } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./skills.ts"; import { makeCachedProviderMaintenanceResolution, makeManualOnlyProviderMaintenanceCapabilities, @@ -75,14 +72,13 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { export type GrokDriverEnv = | GrokAdapterV2DriverEnv - | ProviderHost + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; + | ProviderEventLoggers.ProviderEventLoggers; export const GrokDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -99,8 +95,8 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const host = yield* ProviderHost; - const { cwd } = yield* ServerConfig.ServerConfig; + const host = yield* ProviderHost.ProviderHost; + const { cwd } = host.paths; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/packages/provider-grok/src/server/skills.test.ts similarity index 99% rename from apps/server/src/provider/Drivers/GrokSkills.test.ts rename to packages/provider-grok/src/server/skills.test.ts index 6f194644617f..e05d377e71a6 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/packages/provider-grok/src/server/skills.test.ts @@ -4,7 +4,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; -import { discoverGrokSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./skills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/packages/provider-grok/src/server/skills.ts similarity index 97% rename from apps/server/src/provider/Drivers/GrokSkills.ts rename to packages/provider-grok/src/server/skills.ts index f552b0f4b2bc..1a65c794357c 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/packages/provider-grok/src/server/skills.ts @@ -15,7 +15,8 @@ * * @module provider/Drivers/GrokSkills */ -import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { GrokSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; diff --git a/apps/server/src/provider/GrokProvider.ts b/packages/provider-grok/src/server/status.ts similarity index 99% rename from apps/server/src/provider/GrokProvider.ts rename to packages/provider-grok/src/server/status.ts index 2899583a20c3..0d8dc59b0ddc 100644 --- a/apps/server/src/provider/GrokProvider.ts +++ b/packages/provider-grok/src/server/status.ts @@ -1,12 +1,12 @@ import { type CustomModelSetting, - type GrokSettings, type ModelCapabilities, type ServerProvider, type ServerProviderAuth, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; +import type { GrokSettings } from "../settings.ts"; import type * as EffectAcpSchema from "effect-acp/compat"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as Crypto from "effect/Crypto"; @@ -41,9 +41,9 @@ import { isValidGrokReasoningEffortToken, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, -} from "./acp/GrokAcpSupport.ts"; +} from "./acpSupport.ts"; import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel"; -import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; +import { discoverGrokSkills } from "./skills.ts"; const GROK_PRESENTATION = { displayName: "Grok", diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/packages/provider-grok/src/server/textGeneration.ts similarity index 97% rename from apps/server/src/textGeneration/GrokTextGeneration.ts rename to packages/provider-grok/src/server/textGeneration.ts index 6e14fa101ee2..b908c7e6e00c 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/packages/provider-grok/src/server/textGeneration.ts @@ -6,7 +6,8 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpErrors from "effect-acp/errors"; -import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; +import { TextGenerationError } from "@t3tools/contracts"; +import type { GrokSettings } from "../settings.ts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; @@ -16,7 +17,7 @@ import { currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, -} from "../provider/acp/GrokAcpSupport.ts"; +} from "./acpSupport.ts"; const GROK_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/provider/grokUsageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts similarity index 100% rename from apps/server/src/provider/grokUsageLimits.ts rename to packages/provider-grok/src/server/usageLimits.ts diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts similarity index 99% rename from apps/server/src/provider/acp/XAiAcpExtension.test.ts rename to packages/provider-grok/src/server/xaiAcpExtension.test.ts index 84e5511785b4..5dce41edead9 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts @@ -33,7 +33,7 @@ import { xAiPromptCompleteFromSessionUpdate, xAiSubagentFinishedNotice, XAiAskUserQuestionRequest, -} from "./XAiAcpExtension.ts"; +} from "./xaiAcpExtension.ts"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { type AcpToolCallState, diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts similarity index 100% rename from apps/server/src/provider/acp/XAiAcpExtension.ts rename to packages/provider-grok/src/server/xaiAcpExtension.ts diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts b/packages/provider-grok/src/server/xaiBackgroundTasks.test.ts similarity index 99% rename from apps/server/src/provider/acp/XAiBackgroundTasks.test.ts rename to packages/provider-grok/src/server/xaiBackgroundTasks.test.ts index 93227b260481..34c80e761d40 100644 --- a/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts +++ b/packages/provider-grok/src/server/xaiBackgroundTasks.test.ts @@ -4,7 +4,7 @@ import { TurnId } from "@t3tools/contracts"; import { buildGrokBackgroundTaskEvents, type GrokBackgroundTaskRecord, -} from "./XAiBackgroundTasks.ts"; +} from "./xaiBackgroundTasks.ts"; const turnId = TurnId.make("turn-1"); const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.ts b/packages/provider-grok/src/server/xaiBackgroundTasks.ts similarity index 100% rename from apps/server/src/provider/acp/XAiBackgroundTasks.ts rename to packages/provider-grok/src/server/xaiBackgroundTasks.ts diff --git a/packages/provider-grok/src/settings.ts b/packages/provider-grok/src/settings.ts new file mode 100644 index 000000000000..ab26b2f2148d --- /dev/null +++ b/packages/provider-grok/src/settings.ts @@ -0,0 +1,39 @@ +/** + * Grok instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-grok/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const GrokSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and OpenCode): the binding is not yet + // stable enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("grok").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Grok CLI binary.", + providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type GrokSettings = typeof GrokSettings.Type; diff --git a/packages/provider-grok/src/testing.ts b/packages/provider-grok/src/testing.ts new file mode 100644 index 000000000000..519d76a6d62d --- /dev/null +++ b/packages/provider-grok/src/testing.ts @@ -0,0 +1,38 @@ +/** + * Grok internals the server's replay testkit, fixture recorder, and + * orchestration tests drive directly. + * + * @module provider-grok/testing + */ +export { + GROK_DEFAULT_INSTANCE_ID, + GROK_PROVIDER, + GrokProviderCapabilitiesV2, + grokLaunchRuntimeMode, + makeGrokAcpAdapterFlavor, + makeGrokAdapterV2, + type GrokAdapterV2Options, +} from "./server/adapter.ts"; +export { + GROK_ACP_CANCEL_META, + GROK_ACP_INITIALIZE_META, + makeGrokAcpRuntime, +} from "./server/acpSupport.ts"; +export { + buildGrokModelCapabilities, + buildGrokModelsFromSessionModelState, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + grokSlashCommandsFromInitialize, + parseGrokModelsCliOutput, +} from "./server/status.ts"; +export { makeGrokTextGeneration } from "./server/textGeneration.ts"; +export { grokUsageResponseToLimits, readGrokAccount } from "./server/usageLimits.ts"; +export { + extractXAiAcpSubagentEndNotice, + extractXAiAcpSubagentUpdate, + makeXAiPromptCompletionRuntime, + normalizeXAiAcpToolCallState, + registerXAiBackgroundTaskTracking, + xAiRateLimitedErrorCode, +} from "./server/xaiAcpExtension.ts"; diff --git a/packages/provider-grok/tsconfig.json b/packages/provider-grok/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/provider-grok/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0b0d970c12b..a965df78a824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-grok': + specifier: workspace:* + version: link:../../packages/provider-grok '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -609,6 +612,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-grok': + specifier: workspace:* + version: link:../../packages/provider-grok '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -748,6 +754,9 @@ importers: '@t3tools/provider-cursor': specifier: workspace:* version: link:../../packages/provider-cursor + '@t3tools/provider-grok': + specifier: workspace:* + version: link:../../packages/provider-grok '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -1194,6 +1203,43 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-grok: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-muse: dependencies: '@muse-code/sdk': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 40dfbc7c8d3c..3eabff5674fe 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -33,6 +33,7 @@ const workspaceFiles = [ "packages/provider-pi/package.json", "packages/provider-acp/package.json", "packages/provider-cursor/package.json", + "packages/provider-grok/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/effect-acp/package.json", From 42c6623d492850c747b7ba06c1d0f1e1559d0ad5 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 22:23:20 -0500 Subject: [PATCH 049/377] fix(server): speed up long thread message sync (#17387) --- .../orchestration-v2/ProjectionStore.test.ts | 136 +++++++++++++++--- .../src/orchestration-v2/ProjectionStore.ts | 94 +++++++----- .../orchestration-v2/WireProjection.test.ts | 1 + .../threadHistoryPaging.test.ts | 3 +- .../orchestration-v2/threadHistoryPaging.ts | 5 +- 5 files changed, 179 insertions(+), 60 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 9b48ecd1ac36..e8a3c1bcfc08 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -54,6 +54,23 @@ const driver = ProviderDriverKind.make("codex"); const providerInstanceId = modelSelection.instanceId; const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const traceSqlStatements = () => { + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + return { statements, tracer }; +}; + const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( suffix: string, ) { @@ -590,19 +607,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; } - const statements: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - const query = span.attributes.get("db.query.text"); - if (typeof query === "string") statements.push(query); - }; - return span; - }, - }); + const { statements, tracer } = traceSqlStatements(); const initial = yield* Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(JSON, "parse")), (parse) => @@ -635,10 +640,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/, ); const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail); - assert.include( - topLevel, - "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)", - ); + assert.include(topLevel, "SEARCH item USING INTEGER PRIMARY KEY (rowid=?)"); assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY"); const nodeStatement = statements.find((statement) => statement.includes("WITH RECURSIVE retained"), @@ -779,7 +781,12 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { nativeItemRef: null, runtimeRequestId: null, checkpointScopeId: null, - startedAt: now, + startedAt: + index === 998 + ? null + : index === 999 + ? DateTime.makeUnsafe("2020-01-01T00:00:00.000Z") + : now, completedAt: index === -1 ? null : now, }, }); @@ -838,7 +845,32 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { resolvedAt: null, }, }); - const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 75 }); + const { statements, tracer } = traceSqlStatements(); + const snapshot = yield* projectionStore + .getThreadSnapshotWindow(threadId, { rowLimit: 75 }) + .pipe(Effect.withTracer(tracer)); + const nodeStatement = statements.find( + (statement) => + statement.includes("payload_json") && + statement.includes("orchestration_v2_projection_nodes") && + !statement.includes("WITH RECURSIVE"), + ); + assert.isDefined(nodeStatement); + const nodePlan = yield* (yield* SqlClient.SqlClient).unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${nodeStatement}`, + ); + const nodeLookups = nodePlan + .map((row) => row.detail) + .filter((detail) => detail.startsWith("SEARCH ")); + // Cost must follow the retained cohort, even when the thread has + // thousands of completed nodes outside the requested window. + assert.deepEqual(nodeLookups, [ + "SEARCH node USING INDEX sqlite_autoindex_orchestration_v2_projection_nodes_1 (node_id=?)", + ]); + assert.notInclude( + nodePlan.map((row) => row.detail), + "USE TEMP B-TREE FOR ORDER BY", + ); assert.lengthOf(snapshot.projection.visibleTurnItems, 75); assert.lengthOf(snapshot.projection.nodes, 79); const retained = new Set(snapshot.projection.nodes.map((node) => node.id)); @@ -862,6 +894,27 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ); const full = yield* projectionStore.getThreadSnapshot(threadId); assert.lengthOf(full.projection.nodes, 1003); + // Bounded reads keep the full read's payloads and ordering, including + // null start times and equal timestamps. + assert.deepEqual( + snapshot.projection.nodes, + full.projection.nodes.filter((node) => retained.has(node.id)), + ); + const sql = yield* SqlClient.SqlClient; + const duplicateItemId = TurnItemId.make("item:bounded-node-history:duplicate"); + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + SELECT ${duplicateItemId}, thread_id, run_id, node_id, provider_thread_id, + provider_turn_id, parent_item_id, ordinal + 1, type, status, updated_at, + json_set(payload_json, '$.id', ${duplicateItemId}, '$.ordinal', ordinal + 1) + FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${TurnItemId.make("item:bounded-node-history:999")} + `; + const sharedNodeSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: 76, + }); + assert.lengthOf(sharedNodeSnapshot.projection.visibleTurnItems, 76); + assert.deepEqual(sharedNodeSnapshot.projection.nodes, snapshot.projection.nodes); }), ); @@ -1055,6 +1108,34 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), ); + // Physical insertion order must not change the ordinal/ID timeline order. + const tiedIds = [ + TurnItemId.make("turn-item:bounded-sql-history:tie:b"), + TurnItemId.make("turn-item:bounded-sql-history:tie:a"), + ]; + for (const id of tiedIds) { + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) + SELECT ${id}, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, 2000, type, status, updated_at, + json_set(payload_json, '$.id', ${id}, '$.ordinal', 2000) + FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${itemId} + `; + } + const tied = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: 3 }); + assert.deepEqual( + tied.projection.turnItems.map((item) => item.id), + [TurnItemId.make("turn-item:bounded-sql-history:1000"), tiedIds[1], tiedIds[0]], + ); + yield* sql` + DELETE FROM orchestration_v2_projection_turn_items + WHERE turn_item_id IN ${sql.in(tiedIds)} + `; + const hiddenRunId = RunId.make("run:bounded-sql-history:hidden-suffix"); yield* sql` INSERT INTO orchestration_v2_projection_runs ( @@ -4056,9 +4137,20 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { `; } - const boundedFork = yield* projectionStore.getThreadSnapshotWindow(targetThreadId, { - rowLimit: 2, - }); + const { statements, tracer } = traceSqlStatements(); + const boundedFork = yield* projectionStore + .getThreadSnapshotWindow(targetThreadId, { rowLimit: 2 }) + .pipe(Effect.withTracer(tracer)); + // Only the target's control state is hydrated. Fork ancestors contribute + // timeline rows, runs and attempts, regardless of their own live work. + assert.lengthOf( + statements.filter((statement) => statement.includes("WITH RECURSIVE retained")), + 1, + ); + assert.lengthOf( + statements.filter((statement) => statement.includes("SELECT node.payload_json")), + 1, + ); assert.lengthOf(boundedFork.projection.turnItems, 0); assert.deepEqual( boundedFork.projection.visibleTurnItems.map((row) => [row.visibility, row.item.type]), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 86b2d503c6b2..a6a092ff84b7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -66,7 +66,6 @@ import { import { createOrchestrationV2TurnItemVisibility, isOrchestrationV2SupersededInterrupt, - isOrchestrationV2TurnItemVisible, } from "@t3tools/shared/orchestrationV2Timeline"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Context from "effect/Context"; @@ -1182,22 +1181,18 @@ function sortMessagesByTurnItemOrder( function activeLocalTurnItems( projection: OrchestrationV2ThreadProjection, ): Array { - return projection.turnItems - .filter((item) => - isOrchestrationV2TurnItemVisible({ - item, - runs: projection.runs, - attempts: projection.attempts, - items: projection.turnItems, - }), - ) - .map((item, position) => ({ - position, - visibility: "local" as const, - sourceThreadId: item.threadId, - sourceItemId: item.id, - item, - })); + const isVisible = createOrchestrationV2TurnItemVisibility({ + runs: projection.runs, + attempts: projection.attempts, + items: projection.turnItems, + }); + return projection.turnItems.filter(isVisible).map((item, position) => ({ + position, + visibility: "local" as const, + sourceThreadId: item.threadId, + sourceItemId: item.id, + item, + })); } function localVisibleTurnItems( @@ -2722,7 +2717,7 @@ export const layer: Layer.Layer = ` : yield* sql` WITH eligible AS NOT MATERIALIZED ( - SELECT item.payload_json, item.ordinal, item.turn_item_id, + SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, item.run_id, item.node_id, item.type FROM orchestration_v2_projection_turn_items AS item LEFT JOIN orchestration_v2_projection_runs AS run @@ -2811,7 +2806,7 @@ export const layer: Layer.Layer = -- Choose and sort rows by ID, then fetch payloads in that -- order. A turn window can hold megabytes of tool output, and -- carrying it through the union and sort cost about a second. - SELECT ordinal, turn_item_id, run_id, type + SELECT item_rowid, ordinal, turn_item_id, run_id, type FROM eligible WHERE ordinal >= (SELECT ordinal FROM boundary) ORDER BY ordinal DESC, turn_item_id DESC @@ -2821,9 +2816,9 @@ export const layer: Layer.Layer = ELSE ${window.rowLimit} END ), retained AS MATERIALIZED ( - SELECT ordinal, turn_item_id FROM selected + SELECT item_rowid, ordinal, turn_item_id FROM selected UNION - SELECT request.ordinal, request.turn_item_id + SELECT request.rowid AS item_rowid, request.ordinal, request.turn_item_id FROM orchestration_v2_projection_turn_items AS request WHERE request.run_id IN ( SELECT run_id FROM selected @@ -2831,9 +2826,9 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION - SELECT latest.ordinal, latest.turn_item_id + SELECT latest.item_rowid, latest.ordinal, latest.turn_item_id FROM ( - SELECT ordinal, turn_item_id + SELECT rowid AS item_rowid, ordinal, turn_item_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND ${window.anchorItemId ?? null} IS NULL @@ -2844,11 +2839,12 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ) -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite - -- skips sorting again once the payloads are attached. + -- skips sorting again once the payloads are attached. Carry the + -- rowid through selection to avoid a second string-key index lookup. SELECT item.payload_json FROM retained CROSS JOIN orchestration_v2_projection_turn_items AS item - ON item.turn_item_id = retained.turn_item_id + ON item.rowid = retained.item_rowid ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Reuse the decoded items for cohort IDs and the resulting projection. @@ -2865,7 +2861,11 @@ export const layer: Layer.Layer = // A run can contain thousands of completed nodes. Load the visible // nodes and live control dependencies, then walk only their ancestors. const cohortNodeIds = - window === undefined + window === undefined || + (fields !== undefined && + fields.every( + (field) => field === "runs" || field === "attempts" || field === "turnItems", + )) ? cohortJson("nodeId") : encodeIdList( (yield* sql<{ readonly node_id: string }>` @@ -2900,7 +2900,14 @@ export const layer: Layer.Layer = INNER JOIN retained ON parent.node_id = retained.node_id WHERE parent.thread_id = ${threadId} AND parent.parent_node_id IS NOT NULL ) - SELECT node_id FROM retained WHERE node_id IS NOT NULL + -- Sort IDs before fetching bodies, so large payloads never enter + -- SQLite's temporary sort. The node read restores this order by key. + SELECT retained.node_id + FROM retained + LEFT JOIN orchestration_v2_projection_nodes AS node + ON node.node_id = retained.node_id AND node.thread_id = ${threadId} + WHERE retained.node_id IS NOT NULL + ORDER BY COALESCE(node.started_at, ''), retained.node_id ASC `).map((row) => row.node_id), ); const cohortProviderThreadIds = cohortJson("providerThreadId"); @@ -2970,13 +2977,20 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId} ORDER BY COALESCE(started_at, ''), node_id ASC - ` - : sql` - SELECT payload_json FROM orchestration_v2_projection_nodes - WHERE thread_id = ${threadId} - AND node_id IN (SELECT value FROM json_each(${cohortNodeIds})) - ORDER BY COALESCE(started_at, ''), node_id ASC - `, + ` + : sql` + -- Keep retained IDs outermost so SQLite looks up each node by its + -- primary key instead of scanning every historical node in the thread. + SELECT node.payload_json, retained.key AS position + FROM json_each(${cohortNodeIds}) AS retained + CROSS JOIN orchestration_v2_projection_nodes AS node + ON node.node_id = retained.value + WHERE node.thread_id = ${threadId} + `.pipe( + Effect.map((rows) => + rows.toSorted((left, right) => left.position - right.position), + ), + ), fields !== undefined && !fields.includes("subagents") ? Effect.succeed([]) : window === undefined @@ -3257,7 +3271,7 @@ export const layer: Layer.Layer = visibleTurnItems: [], updatedAt: thread.updatedAt, } satisfies OrchestrationV2ThreadProjection; - return fields === undefined ? withLocalVisibleTurnItems(projection) : projection; + return projection; }).pipe( Effect.mapError((cause) => isProjectionStoreThreadNotFoundError(cause) @@ -3289,7 +3303,15 @@ export const layer: Layer.Layer = (window?.historyAnchor !== undefined && window.historyAnchor.threadId !== threadId) ? { ...window, rowLimit: 0, anchorItemId: undefined } : window; - const projection = yield* readCanonicalProjection(threadId, localWindow); + // Ancestors contribute timeline items, not their provider/control state. + // Keep their runs and attempts for fork boundaries and visibility. + const projection = yield* readCanonicalProjection( + threadId, + localWindow, + window !== undefined && seenThreadIds.size > 0 + ? ["runs", "attempts", "turnItems"] + : undefined, + ); const forkedFrom = projection.thread.forkedFrom; if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { return withLocalVisibleTurnItems(projection); diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index ba4998341db1..a44dc0fe8177 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -142,6 +142,7 @@ describe("orchestration V2 wire projection", () => { deletedAt: null, }, runs: [], + attempts: [], runtimeRequests: [], messages: [ { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 9c1115ca0df7..57a6bf86da58 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -634,7 +634,7 @@ describe("threadHistoryPaging", () => { }); it("preserves oversized actionable state and reports the budget exception", () => { - const projection = makeProjection([]); + const projection = makeProjection([makeRow(0)]); const actionable = { ...projection, plans: [ @@ -654,6 +654,7 @@ describe("threadHistoryPaging", () => { expect(bounded.payloadBudgetExceeded).toBe(true); expect(bounded.projection.plans[0]).toEqual(actionable.plans[0]); + expect(bounded.projection.visibleTurnItems).toEqual(actionable.visibleTurnItems); }); it("keeps paged historical plan detail in the turn item and only status in its artifact", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 8ee7c2641bd3..ce7fccfd7d26 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -567,6 +567,9 @@ export function buildBoundedThreadProjection(input: { // Always from the full projection so inherited-only windows still carry a // watermark for partial live reducers. latestLocalTurnOrdinal, - payloadBudgetExceeded: bytesOfJson(projection) > policy.maxEncodedBytes, + // Adding timeline rows and historical summaries cannot shrink control state. + // Avoid serializing the whole timeline when control state already exceeds the cap. + payloadBudgetExceeded: + controlBytes > policy.maxEncodedBytes || bytesOfJson(projection) > policy.maxEncodedBytes, }; } From c9fa1367caff4cd6595386814e1f8bb950d3d3ef Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 22:30:05 -0500 Subject: [PATCH 050/377] fix(desktop): cancel backend pipe reads to avoid slow shutdown (#17386) --- .../src/preview/DesktopBrowserChannel.test.ts | 109 +++++++++--------- .../src/preview/DesktopBrowserChannel.ts | 6 +- .../testing/DesktopPipeLifecycle.fixture.ts | 57 +++++++++ .../DesktopTelemetryReceiver.ts | 9 +- 4 files changed, 120 insertions(+), 61 deletions(-) create mode 100644 apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts index 4fd769ab3fc6..59cc5e1701a1 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.test.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -1,58 +1,59 @@ -// @effect-diagnostics nodeBuiltinImport:off - The channel reads real file descriptors. -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off - Exercises the real inherited descriptor lifecycle in a subprocess. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; +import { expect, it } from "vite-plus/test"; -import * as ServerConfig from "../config.ts"; -import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; - -const key = { threadId: "thread-1", tabId: "tab-1" }; - -/** The channel over two files: what the desktop sent, and where commands go. */ -const channelOver = (events: ReadonlyArray>) => - Effect.gen(function* () { - const directory = yield* Effect.acquireRelease( - Effect.sync(() => - NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-browser-channel-")), - ), - (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })), - ); - const inbound = NodePath.join(directory, "events.ndjson"); - NodeFS.writeFileSync(inbound, events.map((event) => `${JSON.stringify(event)}\n`).join("")); - const control = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(NodePath.join(directory, "commands.ndjson"), "w")), - (fd) => Effect.sync(() => NodeFS.closeSync(fd)), - ); - const base = yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" })), - ); - const config = Layer.succeed(ServerConfig.ServerConfig, { - ...base, - desktopBrowserFd: NodeFS.openSync(inbound, "r"), - desktopBrowserControlFd: control, - }); - // Built in the caller's scope, so the channel outlives this setup. - const context = yield* Layer.build(DesktopBrowserChannel.layer.pipe(Layer.provide(config))); - return Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); - }); - -it.layer(NodeServices.layer)("DesktopBrowserChannel", (it) => { - it.effect("refuses an endpoint for a tab that is no longer attached", () => - Effect.gen(function* () { - const channel = yield* channelOver([ - { type: "attached", ...key }, - { type: "detached", ...key }, - ]); - // Whether or not the reader has reached these lines yet, the tab is not attached. - const exit = yield* Effect.exit(Effect.scoped(channel.endpoint(key))); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.scoped), +it("receives desktop messages and exits while the parent keeps both input pipes open", async () => { + const child = NodeChildProcess.spawn( + process.execPath, + [NodeURL.fileURLToPath(new URL("./testing/DesktopPipeLifecycle.fixture.ts", import.meta.url))], + { stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", "pipe", "pipe"] }, ); + // A failed exit must not leave a test process running. This never fires on success. + // @effect-diagnostics-next-line globalTimers:off -- Bounds the native subprocess on failure; success waits for process exit. + const watchdog = setTimeout(() => child.kill("SIGKILL"), 8_000); + let output = ""; + let errors = ""; + let verified = false; + child.stderr?.on("data", (chunk: Buffer) => { + errors += chunk.toString(); + }); + const write = (fd: number, value: Record) => { + const stream = child.stdio[fd]; + if (!stream || !("write" in stream)) throw new Error(`Missing pipe ${fd}`); + stream.write(`${JSON.stringify(value)}\n`); + }; + const key = { threadId: "thread-1", tabId: "tab-1" }; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + let end: number; + while ((end = output.indexOf("\n")) >= 0) { + const line = output.slice(0, end); + output = output.slice(end + 1); + if (line === "ready") { + write(3, { type: "attached", ...key }); + write(5, { version: 1, type: "desktopTelemetryHello", electronPid: process.pid }); + } else if (line === "attached") { + write(3, { type: "detached", ...key }); + } else if (line === "verified") { + verified = true; + } + } + }); + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }, + ); + expect(errors).not.toContain("Error"); + expect(verified).toBe(true); + expect(result).toEqual({ code: 0, signal: null }); + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + for (const stream of child.stdio) stream?.destroy(); + } }); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 99f89f3544c3..000fe9508027 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -27,7 +27,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; import * as NodeCrypto from "node:crypto"; -import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; import * as ServerConfig from "../config.ts"; import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -102,8 +102,10 @@ const make = Effect.gen(function* () { ), ); + // Socket reads can be cancelled while the desktop keeps its write end open. + // Filesystem reads on this pipe keep Node alive after scope shutdown. const readable = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.createReadStream("", { fd: inputFd, autoClose: true })), + Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), (stream) => Effect.sync(() => stream.destroy()), ); yield* NodeStream.fromReadable({ diff --git a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts new file mode 100644 index 000000000000..b9fee70bf39f --- /dev/null +++ b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts @@ -0,0 +1,57 @@ +// @effect-diagnostics nodeBuiltinImport:off - Entry point for the inherited-pipe shutdown regression test. +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Console from "effect/Console"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import * as ServerSettings from "../../serverSettings.ts"; + +import * as ServerConfig from "../../config.ts"; +import * as DesktopTelemetryReceiver from "../../resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as DesktopBrowserChannel from "../DesktopBrowserChannel.ts"; + +const key = { threadId: "thread-1", tabId: "tab-1" }; + +Effect.gen(function* () { + const base = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pipe-lifecycle-" })), + ); + const context = yield* Layer.build( + Layer.merge(DesktopBrowserChannel.layer, DesktopTelemetryReceiver.layer).pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.succeed(ServerConfig.ServerConfig, { + ...base, + desktopBrowserFd: 3, + desktopBrowserControlFd: 4, + desktopTelemetryFd: 5, + desktopTelemetryControlFd: 6, + }), + ), + ), + ); + const browser = Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); + const telemetry = Context.get(context, DesktopTelemetryReceiver.DesktopTelemetryReceiver); + const health = yield* telemetry.subscribeHealth; + const healthy = yield* health.changes.pipe( + Stream.filter((value) => value.status === "healthy"), + Stream.runHead, + Effect.forkScoped, + ); + const detached = yield* browser.detached.pipe(Stream.runHead, Effect.forkScoped); + yield* Console.log("ready"); + if (!(yield* browser.awaitAttached(key, "5 seconds"))) { + return yield* Effect.die("The browser attach message was not received."); + } + yield* Fiber.join(healthy); + yield* Console.log("attached"); + yield* Fiber.join(detached); + const endpoint = yield* Effect.exit(Effect.scoped(browser.endpoint(key))); + if (Exit.isSuccess(endpoint)) return yield* Effect.die("A detached tab received an endpoint."); + yield* Console.log("verified"); +}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index cb9bee58f7cd..01a6be1c86f3 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; import * as NodeStream from "@effect/platform-node/NodeStream"; import { @@ -463,13 +464,11 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (config.desktopTelemetryFd !== undefined) { const fd = config.desktopTelemetryFd; + // The parent waits for this server before closing its pipe. Socket reads + // can be cancelled; filesystem reads would keep process.exit waiting. const readable = yield* Effect.acquireRelease( Effect.try({ - try: () => - NodeFS.createReadStream("", { - fd, - autoClose: true, - }), + try: () => new NodeNet.Socket({ fd, readable: true, writable: false }), catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), }), (stream) => From b000eac171318bf0130865387ae33523eecd3019 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 22:03:32 -0700 Subject: [PATCH 051/377] refactor(providers): adapter factories yield their services (#17381) --- .../scripts/record-grok-acp-replay-fixture.ts | 9 +- .../Adapters/AcpAdapterV2.test.ts | 709 +++--------------- .../Adapters/AcpRegistryAdapterV2.test.ts | 24 +- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 11 +- .../Adapters/AcpRegistryAdapterV2.ts | 26 +- .../Adapters/AntigravityAdapterV2.test.ts | 24 +- .../Adapters/AntigravityAdapterV2.ts | 40 +- .../Adapters/GrokAdapterV2.test.ts | 21 +- .../Adapters/GrokAdapterV2.testkit.ts | 12 +- .../Adapters/MuseAdapterV2.testkit.ts | 7 +- .../src/provider/Drivers/AntigravityDriver.ts | 8 +- packages/provider-acp/src/server/adapter.ts | 25 +- .../provider-core/src/server/ProviderHost.ts | 4 +- .../src/server/managedProvider.test.ts | 4 +- .../src/server/snapshotSettings.ts | 2 +- .../src/server/adapter.test.ts | 51 +- .../provider-cursor/src/server/adapter.ts | 44 +- .../src/server/credentialStore.test.ts | 1 - packages/provider-grok/src/server/adapter.ts | 51 +- .../provider-muse/src/server/adapter.test.ts | 12 +- packages/provider-muse/src/server/adapter.ts | 26 +- packages/provider-muse/src/server/driver.ts | 6 +- .../src/server/adapter.test.ts | 56 +- .../provider-opencode/src/server/adapter.ts | 29 +- .../src/server/v2/textGeneration.ts | 2 +- .../provider-pi/src/server/adapter.test.ts | 38 +- packages/provider-pi/src/server/adapter.ts | 58 +- 27 files changed, 270 insertions(+), 1030 deletions(-) diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index d7c7da2c8e08..fbf381f50537 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -16,7 +16,6 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -25,7 +24,6 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_DEFAULT_INSTANCE_ID, @@ -447,16 +445,11 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = yield* HostProcessEnvironment; - const adapter = makeGrokAdapterV2({ + const adapter = yield* makeGrokAdapterV2({ instanceId: GROK_DEFAULT_INSTANCE_ID, settings, environment, hostPlatform: yield* HostProcessPlatform, - childProcessSpawner, - crypto: yield* Crypto.Crypto, - fileSystem: yield* FileSystem.FileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, // Production's runtime factory, with the protocol logger teeing raw lines. diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 62bff5add8ef..a8c958209214 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -30,7 +30,6 @@ import { GrokSettings } from "@t3tools/provider-grok/settings"; import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import type * as Duration from "effect/Duration"; import * as Cause from "effect/Cause"; @@ -54,7 +53,6 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -610,12 +608,8 @@ describe("AcpAdapterV2", () => { const path = yield* Path.Path; const instanceId = ProviderInstanceId.make(`vibe-retry-${outcome}`); const threadId = ThreadId.make(`thread-vibe-retry-${outcome}`); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, - fileSystem: yield* FileSystem.FileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ProviderDriverKind.make("acpRegistry"), @@ -709,10 +703,7 @@ describe("AcpAdapterV2", () => { it.effect("starts the MCP bridge directly from the self-contained runtime", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation().pipe( Effect.provideService(HostProcessIsExecutable, true), ); @@ -739,8 +730,7 @@ describe("AcpAdapterV2", () => { let runtimeInput: AcpAdapterV2RuntimeInput | undefined; const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath }); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -750,9 +740,6 @@ describe("AcpAdapterV2", () => { runtimeInput = input; }).pipe(Effect.andThen(makeRuntime(input))), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ @@ -782,10 +769,7 @@ describe("AcpAdapterV2", () => { it.live("refreshes ACP prompt instructions when the interaction mode changes", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -805,17 +789,13 @@ describe("AcpAdapterV2", () => { yield* Effect.addFinalizer(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), ); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const policy = (interactionMode: "default" | "plan") => @@ -909,10 +889,7 @@ describe("AcpAdapterV2", () => { it.effect("starts a new replay message after ACP v2 plan boundaries", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -920,8 +897,7 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test-v2-plan-replay-boundary"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -938,9 +914,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); @@ -1043,12 +1016,8 @@ describe("AcpAdapterV2", () => { ]; }; const instanceId = ProviderInstanceId.make("devin-streamed-write"); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ instanceId, - crypto: yield* Crypto.Crypto, - fileSystem: yield* FileSystem.FileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1178,7 +1147,6 @@ describe("AcpAdapterV2", () => { it.effect("keeps Devin parent paragraphs intact while projecting native child work", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1187,14 +1155,10 @@ describe("AcpAdapterV2", () => { let handler: Parameters[0] | undefined; let createTerminal: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("devin-replay"); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ instanceId, // Production Devin runs commands through client terminals. clientTerminals: { childProcessSpawner, shellCommands: true }, - crypto: yield* Crypto.Crypto, - fileSystem: yield* FileSystem.FileSystem, - idAllocator, - host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), flavor: { driver: ACP_TEST_DRIVER, @@ -1511,10 +1475,7 @@ describe("AcpAdapterV2", () => { it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1522,8 +1483,7 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test-v2-fidelity"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -1541,9 +1501,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-fidelity"); @@ -1705,18 +1662,15 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("acp-test-eager-resume"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -1727,9 +1681,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-eager-resume"); @@ -1790,18 +1741,15 @@ describe("AcpAdapterV2", () => { it.live("preserves new-session fallback when an eager ACP session load is stale", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("acp-test-stale-eager-resume"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -1814,9 +1762,6 @@ describe("AcpAdapterV2", () => { runtimeOrdinal === 1 ? { T3_ACP_FAIL_LOAD_SESSION: "1" } : {}, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-eager-resume"); @@ -1936,10 +1881,7 @@ describe("AcpAdapterV2", () => { it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -1955,8 +1897,7 @@ describe("AcpAdapterV2", () => { }, }); const instanceId = ProviderInstanceId.make("acp-test-unexpected-termination"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -1966,9 +1907,6 @@ describe("AcpAdapterV2", () => { runtimeInputs.push(runtimeInput); }).pipe(Effect.andThen(baseMakeRuntime(runtimeInput))), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unexpected-termination"); @@ -2020,9 +1958,7 @@ describe("AcpAdapterV2", () => { if ((yield* HostProcessPlatform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2034,8 +1970,7 @@ describe("AcpAdapterV2", () => { Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), ); const instanceId = ProviderInstanceId.make("acp-test-provider-exit"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2055,9 +1990,6 @@ describe("AcpAdapterV2", () => { }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); @@ -2104,10 +2036,7 @@ describe("AcpAdapterV2", () => { Effect.gen(function* () { if ((yield* HostProcessPlatform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2116,8 +2045,7 @@ describe("AcpAdapterV2", () => { | AcpSessionRuntime.AcpSessionRuntime["Service"]["processContainment"] | undefined; const instanceId = ProviderInstanceId.make("acp-test-cgroup-unavailable"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2134,9 +2062,6 @@ describe("AcpAdapterV2", () => { }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); @@ -2160,10 +2085,7 @@ describe("AcpAdapterV2", () => { it.live("cleans a cgroup lease when the pre-exec join wrapper fails", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2192,8 +2114,7 @@ describe("AcpAdapterV2", () => { }, }; const instanceId = ProviderInstanceId.make("acp-test-cgroup-join-failure"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2206,9 +2127,6 @@ describe("AcpAdapterV2", () => { ownDetachedProcessGroup: true, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); @@ -2238,10 +2156,7 @@ describe("AcpAdapterV2", () => { it.effect("negotiates and executes optional native session forks through the ACP runtime", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2275,17 +2190,13 @@ describe("AcpAdapterV2", () => { }); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime, }, - fileSystem, - idAllocator, - host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2451,9 +2362,7 @@ describe("AcpAdapterV2", () => { Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2465,8 +2374,7 @@ describe("AcpAdapterV2", () => { const probeLogPath = path.join(workspace, "fs-probe.jsonl"); const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("acp-test-no-client-fs"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2481,9 +2389,6 @@ describe("AcpAdapterV2", () => { }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-no-client-fs"); @@ -2547,10 +2452,7 @@ describe("AcpAdapterV2", () => { it.effect("does not turn an unknown permission approval into an execute grant", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2576,8 +2478,7 @@ describe("AcpAdapterV2", () => { }), }); const instanceId = ProviderInstanceId.make("acp-test-unknown-permission-grant"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2588,9 +2489,6 @@ describe("AcpAdapterV2", () => { runtimeInput = input; }).pipe(Effect.andThen(makeRuntime(input))), }, - fileSystem, - idAllocator, - host, selfInvocation, clientTerminals: { childProcessSpawner }, }); @@ -2684,26 +2582,19 @@ describe("AcpAdapterV2", () => { it.effect("fails missing native ACP session ids through the typed start-turn error channel", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const instanceId = ProviderInstanceId.make("acp-test-missing-native-thread"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-native-thread"); @@ -2746,10 +2637,8 @@ describe("AcpAdapterV2", () => { it.effect("replaces the ACP session and clears conversation state on rollback", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2774,8 +2663,7 @@ describe("AcpAdapterV2", () => { mockAgentPath, environment: { T3_ACP_PROMPT_DELAY_MS: "100" }, }); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2785,9 +2673,6 @@ describe("AcpAdapterV2", () => { Effect.andThen(makeRuntime(runtimeInput)), ), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-session"); @@ -2913,10 +2798,7 @@ describe("AcpAdapterV2", () => { it.effect("quarantines callbacks from a failed rollback replacement before retrying", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -2979,8 +2861,7 @@ describe("AcpAdapterV2", () => { }, }); const instanceId = ProviderInstanceId.make("acp-test-rollback-retry-generation"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -2998,9 +2879,6 @@ describe("AcpAdapterV2", () => { }), makeRuntime, }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -3074,10 +2952,8 @@ describe("AcpAdapterV2", () => { it.effect("keeps the original ACP session usable when a staged replacement terminates", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3109,17 +2985,13 @@ describe("AcpAdapterV2", () => { }; }); const instanceId = ProviderInstanceId.make("acp-test-rollback-failure-compensation"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime, }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); @@ -3190,27 +3062,20 @@ describe("AcpAdapterV2", () => { it.effect("closes an idle ACP session exactly once through the transition permit", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-idle-finalizer"); @@ -3241,26 +3106,18 @@ describe("AcpAdapterV2", () => { (model) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("grok-test"); - const adapter = makeGrokAdapterV2({ + const adapter = yield* makeGrokAdapterV2({ instanceId, settings: DEFAULT_GROK_SETTINGS, environment: {}, hostPlatform: yield* HostProcessPlatform, - childProcessSpawner, - crypto: yield* Crypto.Crypto, - fileSystem, - idAllocator, - host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3296,26 +3153,18 @@ describe("AcpAdapterV2", () => { it.live("Grok reapplies an explicit return to the session's setup-time model", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("grok-test-switch-back"); - const adapter = makeGrokAdapterV2({ + const adapter = yield* makeGrokAdapterV2({ instanceId, settings: DEFAULT_GROK_SETTINGS, environment: {}, hostPlatform: yield* HostProcessPlatform, - childProcessSpawner, - crypto: yield* Crypto.Crypto, - fileSystem, - idAllocator, - host, selfInvocation: yield* resolveSelfInvocation(), // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => @@ -3377,26 +3226,19 @@ describe("AcpAdapterV2", () => { it.effect("skips requested options that the active ACP session does not expose", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsupported-option"); @@ -3426,27 +3268,20 @@ describe("AcpAdapterV2", () => { it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); @@ -3537,18 +3372,15 @@ describe("AcpAdapterV2", () => { it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -3565,9 +3397,6 @@ describe("AcpAdapterV2", () => { restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-empty-successful-bash"); @@ -3684,16 +3513,12 @@ describe("AcpAdapterV2", () => { it.effect("drains native ACP cancellation before admitting the next prompt", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const protocolEvents = yield* Queue.unbounded(); const native: { current?: AcpSessionRuntime.AcpSessionRuntime["Service"] } = {}; const instanceId = ProviderInstanceId.make("acp-native-cancel"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -3712,9 +3537,6 @@ describe("AcpAdapterV2", () => { }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-native-cancel"); @@ -3798,18 +3620,14 @@ describe("AcpAdapterV2", () => { it.effect("cancels pending permission requests while interrupting an ACP turn", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const releaseCancel = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -3821,9 +3639,6 @@ describe("AcpAdapterV2", () => { wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-permission"); @@ -3899,10 +3714,7 @@ describe("AcpAdapterV2", () => { it.live("keeps hard teardown excluded until a permission response is enqueued", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -3910,8 +3722,7 @@ describe("AcpAdapterV2", () => { const responseEnqueued = yield* Deferred.make(); const releaseResponseAcknowledgement = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -3927,9 +3738,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-response-wins-permission"); @@ -3998,10 +3806,7 @@ describe("AcpAdapterV2", () => { it.live("carries elicitation request identity through the completed stdout write", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4009,8 +3814,7 @@ describe("AcpAdapterV2", () => { const responseWritten = yield* Deferred.make(); const releaseResponseAcknowledgement = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4026,9 +3830,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-reordered-elicitation"); @@ -4087,17 +3888,13 @@ describe("AcpAdapterV2", () => { it.live("auto-approves tagged MCP elicitations under full-access policy", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const instanceId = ProviderInstanceId.make("acp-test-mcp-approval-elicitation"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4108,9 +3905,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_MCP_TOOL_APPROVAL_ELICITATION: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); @@ -4155,10 +3949,7 @@ describe("AcpAdapterV2", () => { it.live("fails a held native response acknowledgement before normal session close", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4167,8 +3958,7 @@ describe("AcpAdapterV2", () => { const releaseResponseAcknowledgement = yield* Deferred.make(); const responseLifecycle: Array = []; const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4184,9 +3974,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4263,10 +4050,7 @@ describe("AcpAdapterV2", () => { it.live("rejects delayed native response registration when normal close wins the permit", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4277,8 +4061,7 @@ describe("AcpAdapterV2", () => { const releaseTransportClose = yield* Deferred.make(); const responseLifecycle: Array = []; const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4289,9 +4072,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, testHooks: { afterNativeResponseTransportClosed: () => @@ -4377,10 +4157,7 @@ describe("AcpAdapterV2", () => { it.live("bounds a missing pending permission response acknowledgement", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4390,8 +4167,7 @@ describe("AcpAdapterV2", () => { const responseLifecycle: Array = []; const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4423,9 +4199,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, testHooks: { onNativeResponseLifecycle: (event) => @@ -4518,10 +4291,7 @@ describe("AcpAdapterV2", () => { it.live("defers caller cancellation until a pending response acknowledgement is bounded", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4529,8 +4299,7 @@ describe("AcpAdapterV2", () => { const responseEnqueued = yield* Deferred.make(); const releaseNativeHook = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4561,9 +4330,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-pending-response-cancel"); @@ -4634,10 +4400,8 @@ describe("AcpAdapterV2", () => { it.live("waits for immediate allow and deny permission responses before hard teardown", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4651,8 +4415,7 @@ describe("AcpAdapterV2", () => { const responseEnqueued = yield* Deferred.make(); const releaseResponseAcknowledgement = yield* Deferred.make(); const instanceId = ProviderInstanceId.make(`acp-test-${name}`); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4676,9 +4439,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); @@ -4739,10 +4499,8 @@ describe("AcpAdapterV2", () => { it.live("waits for immediate URL elicitation responses before hard teardown", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4750,8 +4508,7 @@ describe("AcpAdapterV2", () => { const responseEnqueued = yield* Deferred.make(); const releaseResponseAcknowledgement = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test-url-elicitation"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4775,9 +4532,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); @@ -4832,10 +4586,8 @@ describe("AcpAdapterV2", () => { it.live("bounds a missing immediate response acknowledgement before hard teardown", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4843,8 +4595,7 @@ describe("AcpAdapterV2", () => { const responseEnqueued = yield* Deferred.make(); const releaseNativeHook = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4872,9 +4623,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-response-ack"); @@ -4925,10 +4673,7 @@ describe("AcpAdapterV2", () => { it.live("rejects an elicitation response when hard teardown wins admission", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -4936,8 +4681,7 @@ describe("AcpAdapterV2", () => { const teardownStarted = yield* Deferred.make(); const releaseTeardown = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -4961,9 +4705,6 @@ describe("AcpAdapterV2", () => { ), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); @@ -5038,18 +4779,15 @@ describe("AcpAdapterV2", () => { it.effect("releases an ACP turn when cancellation times out", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const instanceId = ProviderInstanceId.make("acp-test"); const protocolEvents = yield* Queue.bounded(256); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5061,9 +4799,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-timeout"); @@ -5141,18 +4876,15 @@ describe("AcpAdapterV2", () => { it.live("treats a second hard Stop as success when the turn is already gone", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5168,9 +4900,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-double-stop"); @@ -5239,22 +4968,19 @@ describe("AcpAdapterV2", () => { it.effect("finalizes a settled turn held open for background work when interrupted", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const instanceId = ProviderInstanceId.make("acp-test"); const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5278,9 +5004,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); @@ -5335,10 +5058,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5346,12 +5067,11 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5388,9 +5108,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-subagent-carryover"); @@ -5483,10 +5200,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5494,12 +5209,11 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5534,9 +5248,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5630,10 +5341,8 @@ describe("AcpAdapterV2", () => { it.effect("handles a child terminal after carryover rehydrate", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5647,12 +5356,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5704,9 +5412,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -5867,10 +5572,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -5903,12 +5606,11 @@ describe("AcpAdapterV2", () => { | Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -5955,9 +5657,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6090,10 +5789,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6106,12 +5803,11 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -6154,9 +5850,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -6309,10 +6002,8 @@ describe("AcpAdapterV2", () => { it.effect("finishes a settled root's carryover subagent from its structured end", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6321,8 +6012,7 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd"; let finishSubagent: AcpAdapterV2ExtensionContext["finishSubagent"] | undefined; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -6352,9 +6042,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -6457,10 +6144,8 @@ describe("AcpAdapterV2", () => { it.effect("projects completed-root carryover eagerly and drain cannot resurrect it", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6472,8 +6157,7 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -6517,9 +6201,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -6709,10 +6390,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -6724,12 +6403,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -6771,9 +6449,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -6997,10 +6672,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7013,12 +6686,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -7069,9 +6741,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -7238,10 +6907,8 @@ describe("AcpAdapterV2", () => { it.effect("projects an interrupted root-session end notice at the next attach", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7254,12 +6921,11 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -7313,9 +6979,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -7467,10 +7130,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7483,12 +7144,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -7530,9 +7190,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -7738,10 +7395,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7753,12 +7408,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -7800,9 +7454,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -7980,10 +7631,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -7993,12 +7642,11 @@ describe("AcpAdapterV2", () => { let cancelCalled = false; let runtimeOrdinalSeen = 0; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8050,9 +7698,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.andThen(cancel)), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-steer"); @@ -8156,18 +7801,15 @@ describe("AcpAdapterV2", () => { it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8189,9 +7831,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); @@ -8246,10 +7885,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8259,8 +7896,7 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test"); let cancelCalled = false; let runtimeOrdinalSeen = 0; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8295,9 +7931,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.andThen(cancel)), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -8440,10 +8073,8 @@ describe("AcpAdapterV2", () => { it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8459,8 +8090,7 @@ describe("AcpAdapterV2", () => { }) => Effect.Effect) | null; } = { current: null }; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8481,9 +8111,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8587,10 +8214,8 @@ describe("AcpAdapterV2", () => { it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8598,8 +8223,7 @@ describe("AcpAdapterV2", () => { const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); let runtimeOrdinalSeen = 0; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8627,9 +8251,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -8718,10 +8339,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -8736,12 +8355,11 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -8796,9 +8414,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); @@ -9022,10 +8637,8 @@ describe("AcpAdapterV2", () => { it.live("Direct Stop projects an interrupt-deferred terminal exactly once", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9036,8 +8649,7 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9084,9 +8696,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -9219,10 +8828,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9238,8 +8845,7 @@ describe("AcpAdapterV2", () => { // interrupt then ORs wire-done with promptSettled under the permit. const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9295,9 +8901,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); @@ -9389,10 +8992,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9400,8 +9001,7 @@ describe("AcpAdapterV2", () => { const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9428,9 +9028,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -9520,10 +9117,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9532,8 +9127,7 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9551,9 +9145,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -9640,10 +9231,8 @@ describe("AcpAdapterV2", () => { it.effect("keeps a buffered continuation current when a user turn starts before dispatch", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9656,8 +9245,7 @@ describe("AcpAdapterV2", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9687,9 +9275,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -9852,7 +9437,6 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -9861,8 +9445,7 @@ describe("AcpAdapterV2", () => { const triggerPath = path.join(triggerDir, "report-trigger"); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -9894,9 +9477,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-injected-report-hold"); @@ -10018,7 +9598,6 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10037,8 +9616,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -10075,9 +9653,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -10217,10 +9792,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10240,8 +9813,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -10273,9 +9845,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -10457,10 +10026,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10480,8 +10047,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -10520,9 +10086,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -10676,10 +10239,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10699,8 +10260,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -10743,9 +10303,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -10891,10 +10448,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -10914,8 +10469,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -10980,9 +10534,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -11263,10 +10814,8 @@ describe("AcpAdapterV2", () => { it.effect("a wake names work that ended while the previous wake was queued", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11277,8 +10826,7 @@ describe("AcpAdapterV2", () => { let applyMutation: AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined; const promptGate = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -11303,9 +10851,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -11417,10 +10962,8 @@ describe("AcpAdapterV2", () => { it.effect("mid-turn completed mutation defers offer until finalize only when unhandled", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11440,8 +10983,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -11487,9 +11029,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -11748,10 +11287,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11771,8 +11308,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -11799,9 +11335,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -11935,10 +11468,8 @@ describe("AcpAdapterV2", () => { it.effect("empty-drain continuation turn waits the quiet window so late frames can attach", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -11958,8 +11489,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -11990,9 +11520,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -12179,10 +11706,8 @@ describe("AcpAdapterV2", () => { it.effect("empty-drain continuation turn finalizes after the quiet window with no frames", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12202,8 +11727,7 @@ describe("AcpAdapterV2", () => { | null; } = { current: null }; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -12232,9 +11756,6 @@ describe("AcpAdapterV2", () => { }), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -12369,18 +11890,15 @@ describe("AcpAdapterV2", () => { it.effect("restarts the ACP child process before the next prompt after interrupt", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -12392,9 +11910,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); @@ -12618,7 +12133,6 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12635,8 +12149,7 @@ describe("AcpAdapterV2", () => { const releaseTeardown = yield* Deferred.make(); let terminatorCallCount = 0; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -12665,9 +12178,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: (request) => @@ -12833,18 +12343,15 @@ describe("AcpAdapterV2", () => { it.live("durably poisons start and resume when required hard teardown is unavailable", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -12858,9 +12365,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); @@ -12922,10 +12426,8 @@ describe("AcpAdapterV2", () => { it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -12935,8 +12437,7 @@ describe("AcpAdapterV2", () => { const releaseTeardown = yield* Deferred.make(); let runtimeOrdinalSeen = 0; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -12960,9 +12461,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); @@ -13035,10 +12533,8 @@ describe("AcpAdapterV2", () => { it.live("quarantines old-runtime callbacks after successful hard teardown", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13101,8 +12597,7 @@ describe("AcpAdapterV2", () => { }; }, }); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -13119,9 +12614,6 @@ describe("AcpAdapterV2", () => { runtimeInputs.push(runtimeInput); }).pipe(Effect.andThen(makeRuntime(runtimeInput))), }, - fileSystem, - idAllocator, - host, selfInvocation, testHooks: { afterHardTeardownTransportDrained: () => @@ -13397,18 +12889,14 @@ describe("AcpAdapterV2", () => { it.live("keeps stale deferred cleanup inert while replacement requests remain live", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -13425,9 +12913,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); @@ -13522,10 +13007,8 @@ describe("AcpAdapterV2", () => { it.live("resolves owner cancellation and concurrent resume waiters after hard teardown", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13535,8 +13018,7 @@ describe("AcpAdapterV2", () => { const releaseTeardown = yield* Deferred.make(); let runtimeOrdinalSeen = 0; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -13560,9 +13042,6 @@ describe("AcpAdapterV2", () => { protocolEvents, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); @@ -13659,7 +13138,6 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13673,8 +13151,7 @@ describe("AcpAdapterV2", () => { ); let cancelCalled = false; const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -13711,9 +13188,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.andThen(Effect.never)), }), }, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests: { offer: () => Effect.void }, }); @@ -13933,10 +13407,8 @@ describe("AcpAdapterV2", () => { () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -13944,12 +13416,11 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; const promptSettled = yield* Deferred.make(); - const adapter = makeAcpAdapterV2({ + const adapter = yield* makeAcpAdapterV2({ testHooks: { afterPromptSettledWithBackgroundWork: () => Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - crypto: yield* Crypto.Crypto, instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -13984,9 +13455,6 @@ describe("AcpAdapterV2", () => { environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); @@ -14090,7 +13558,6 @@ describe("AcpAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -14103,8 +13570,7 @@ describe("AcpAdapterV2", () => { Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), ); const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = makeAcpAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, @@ -14133,9 +13599,6 @@ describe("AcpAdapterV2", () => { wrapCancel: (cancel) => cancel.pipe(Effect.andThen(Effect.sleep("250 millis"))), }), }, - fileSystem, - idAllocator, - host, selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-active-in-process"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fa861a22dde7..3762cbb6769a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -4,7 +4,6 @@ import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contra import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -188,8 +187,7 @@ describe("AcpRegistryAdapterV2", () => { ACP_REGISTRY_PROVIDER, ); const instanceId = ProviderInstanceId.make("acp-registry-mode-pick"); - const adapter = makeAcpRegistryAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpRegistryAdapterV2({ selfInvocation: yield* resolveSelfInvocation(), instanceId, settings: yield* decodeAcpRegistryAdapterSettings({ @@ -198,10 +196,7 @@ describe("AcpRegistryAdapterV2", () => { }), environment: {}, childProcessSpawner, - fileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, resolver: { resolve: () => Effect.die("the runtime is injected") }, - host: yield* ProviderHost.ProviderHost, makeRuntime: makeAcpReplayRuntime({ transcript, statusPath, @@ -268,10 +263,7 @@ describe("AcpRegistryAdapterV2", () => { it.effect("offers client terminals to Devin only and client fs to no registry agent", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); @@ -280,17 +272,13 @@ describe("AcpRegistryAdapterV2", () => { ) { let clientCapabilities: unknown; const instanceId = ProviderInstanceId.make(`acp-registry-capabilities-${agentId}`); - const adapter = makeAcpRegistryAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpRegistryAdapterV2({ selfInvocation: yield* resolveSelfInvocation(), instanceId, settings: yield* decodeAcpRegistryAdapterSettings({ agentId, authMethodId: "test" }), environment: {}, childProcessSpawner, - fileSystem, - idAllocator, resolver: { resolve: () => Effect.die("the runtime is injected") }, - host, makeRuntime: (input) => Effect.gen(function* () { clientCapabilities = input.clientCapabilities; @@ -344,8 +332,6 @@ describe("AcpRegistryAdapterV2", () => { it.effect("opens a real ACP child process resolved from registry configuration", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const mockAgentPath = yield* path.fromFileUrl( @@ -369,8 +355,7 @@ describe("AcpRegistryAdapterV2", () => { readonly commands: AcpRegistryAvailableCommands; }>(); const configurationPublished = yield* Deferred.make(); - const adapter = makeAcpRegistryAdapterV2({ - crypto: yield* Crypto.Crypto, + const adapter = yield* makeAcpRegistryAdapterV2({ selfInvocation: yield* resolveSelfInvocation(), instanceId, settings, @@ -379,8 +364,6 @@ describe("AcpRegistryAdapterV2", () => { T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "750", }, childProcessSpawner, - fileSystem, - idAllocator, runtimeCoordinator: { withForegroundStartup: (agentId, effect) => Effect.acquireUseRelease( @@ -428,7 +411,6 @@ describe("AcpRegistryAdapterV2", () => { })), ), }, - host, }); const threadId = ThreadId.make("thread-acp-registry-fixture"); const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 9ebe08e40ec1..aefcd190f33d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -2,14 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistrySettings } from "@t3tools/contracts"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; @@ -44,9 +42,6 @@ function layerAcpRegistryProviderAdapterRegistryReplay( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost.ProviderHost; const replayGate = options.replayGate; const replayDir = yield* fileSystem .makeTempDirectory({ @@ -57,18 +52,14 @@ function layerAcpRegistryProviderAdapterRegistryReplay( const scriptPath = yield* path .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url)) .pipe(Effect.orDie); - const adapter = makeAcpRegistryAdapterV2({ + const adapter = yield* makeAcpRegistryAdapterV2({ instanceId: ACP_REGISTRY_DEFAULT_INSTANCE_ID, settings: REPLAY_SETTINGS, environment: {}, childProcessSpawner, - crypto, - fileSystem, - idAllocator, resolver: { resolve: () => Effect.die("ACP registry resolver must not run during replay"), }, - host, selfInvocation: yield* resolveSelfInvocation(), makeRuntime: makeAcpReplayRuntime({ transcript, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index d188f9c9266f..99168e136592 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -59,13 +59,9 @@ export interface AcpRegistryAdapterV2Options { readonly settings: AcpRegistrySettings; readonly environment: NodeJS.ProcessEnv; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly crypto: Crypto.Crypto; readonly selfInvocation: SelfInvocation; - readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly resolver: Pick; readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator["Service"]; - readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly makeRuntime?: ( input: AcpAdapterV2RuntimeInput, @@ -181,7 +177,9 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { }); } -export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { +export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(function* ( + options: AcpRegistryAdapterV2Options, +) { const runtimeCoordinator = options.runtimeCoordinator; const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; const startupKey = @@ -237,13 +235,9 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { }), ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), }; - return makeAcpAdapterV2({ + return yield* makeAcpAdapterV2({ instanceId: options.instanceId, flavor, - crypto: options.crypto, - fileSystem: options.fileSystem, - idAllocator: options.idAllocator, - host: options.host, selfInvocation: options.selfInvocation, // Per-agent exception (see the note above registerMistralVibeAcpExtensions): // Devin runs commands through client terminals and has no ask mode over @@ -259,7 +253,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { : {}), ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), }); -} +}); export type AcpRegistryAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner @@ -283,29 +277,21 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< const hostEnvironment = yield* HostProcessEnvironment; const selfInvocation = yield* resolveSelfInvocation(); const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const host = yield* ProviderHost.ProviderHost; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); const resolver = yield* AcpRegistrySupport.AcpRegistryCatalog; const runtimeCoordinator = yield* Effect.serviceOption( AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, ); - return makeAcpRegistryAdapterV2({ + return yield* makeAcpRegistryAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), childProcessSpawner, - crypto, - fileSystem, - idAllocator, resolver, ...(Option.isSome(runtimeCoordinator) ? { runtimeCoordinator: runtimeCoordinator.value } : {}), - host, selfInvocation, nativeLogging: (threadId) => makeNativeLogger({ diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 4ce55c783307..f997751f2fdd 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -38,7 +38,6 @@ const flavor = makeAntigravityAcpAdapterFlavor({ crypto: undefined as never, fileSystem: undefined as never, path: undefined as never, - idAllocator: undefined as never, host: undefined as never, selfInvocation: undefined as never, makeRuntime: () => Effect.die("not spawned in this test"), @@ -149,14 +148,9 @@ describe("AntigravityAdapterV2 client file system", () => { let writeTextFile: Parameters[0] | undefined; const crypto = yield* Crypto.Crypto; const instanceId = ProviderInstanceId.make("antigravity-containment-test"); - const adapter = makeAntigravityAdapterV2({ + const adapter = yield* makeAntigravityAdapterV2({ instanceId, - crypto, selfInvocation: yield* resolveSelfInvocation(), - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -293,7 +287,6 @@ describe("AntigravityAdapterV2 workspace changes", () => { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -301,14 +294,9 @@ describe("AntigravityAdapterV2 workspace changes", () => { type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let readTextFile: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("antigravity-workspace-change-test"); - const adapter = makeAntigravityAdapterV2({ + const adapter = yield* makeAntigravityAdapterV2({ instanceId, - crypto, selfInvocation: yield* resolveSelfInvocation(), - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, @@ -430,7 +418,6 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; const crypto = yield* Crypto.Crypto; const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -451,14 +438,9 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( let readTextFile: Parameters[0] | undefined; let writeTextFile: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make(`antigravity-restrictive-${policy.runtimeMode}`); - const adapter = makeAntigravityAdapterV2({ + const adapter = yield* makeAntigravityAdapterV2({ instanceId, - crypto, selfInvocation: yield* resolveSelfInvocation(), - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host, makeRuntime: (input) => makeAntigravityAcpRuntime({ ...input, diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 4263618cf32c..431cdf0ee3e9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -8,8 +8,8 @@ import { import type { SelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import type * as FileSystem from "effect/FileSystem"; -import type * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -34,7 +34,6 @@ import { makeAntigravityUserInputResponse, normalizeAntigravityToolCall, } from "../../provider/acp/AntigravityProtocol.ts"; -import type { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, @@ -63,12 +62,7 @@ const AntigravityProviderCapabilitiesV2 = { export interface AntigravityAdapterV2Options { readonly instanceId: ProviderInstanceId; - readonly crypto: Crypto.Crypto; readonly selfInvocation: SelfInvocation; - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly idAllocator: IdAllocatorV2["Service"]; - readonly host: ProviderHost.ProviderHostShape; /** Spawns the official agent with the instance's Google profile. */ readonly makeRuntime: ( input: Omit, @@ -118,8 +112,16 @@ const extractAntigravitySubagentUpdate: NonNullable { it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => { const flavor = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, - } as unknown as GrokAdapterV2Options); + } as unknown as Parameters[0]); const limit = flavor.promptFailure?.( new EffectAcpErrors.AcpRequestError({ code: xAiRateLimitedErrorCode, @@ -127,7 +123,7 @@ describe("GrokAdapterV2 capabilities", () => { it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { const flavor = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, - } as unknown as GrokAdapterV2Options); + } as unknown as Parameters[0]); assert.isFalse(flavor.interruptPromptOnCancel); // User Stop (requestRuntimeRestart) keeps the hard process-group kill and @@ -144,7 +140,7 @@ describe("GrokAdapterV2 capabilities", () => { it("terminalizes only foreground tools under the actual Grok flavor", () => { const flavor = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, - } as unknown as GrokAdapterV2Options); + } as unknown as Parameters[0]); const foreground = { toolCallId: "foreground-1", title: "Terminal", @@ -289,7 +285,7 @@ describe("ACP permission policy", () => { describe("Grok permission prompts", () => { const disposition = makeGrokAcpAdapterFlavor({ makeRuntime: () => Effect.never, - } as unknown as GrokAdapterV2Options).permissionDisposition; + } as unknown as Parameters[0]).permissionDisposition; // grok_auto_blocked_command replays Auto end to end. When an explicit policy // launches Grok asking instead, T3's policy still answers its prompts. @@ -332,18 +328,13 @@ describe("Grok launch permission mode", () => { ); }); const instanceId = ProviderInstanceId.make("grok-launch-test"); - const adapter = makeGrokAdapterV2({ + const adapter = yield* makeGrokAdapterV2({ instanceId, settings: LAUNCH_TEST_GROK_SETTINGS, environment: {}, hostPlatform: "darwin", - childProcessSpawner, - crypto: yield* Crypto.Crypto, - fileSystem: yield* FileSystem.FileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, selfInvocation: yield* resolveSelfInvocation(), - }); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); yield* adapter .openSession({ threadId: ThreadId.make("grok-launch-test"), diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 172150432f53..37de61f072ce 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { GrokSettings } from "@t3tools/provider-grok/settings"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -10,7 +9,6 @@ import { ChildProcessSpawner } from "effect/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META } from "@t3tools/provider-grok/testing"; import { makeXAiPromptCompletionRuntime } from "@t3tools/provider-grok/testing"; @@ -45,10 +43,7 @@ function layerGrokProviderAdapterRegistryReplay( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost.ProviderHost; // Same queue the continuation worker drains when the fixture runs it. const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const replayGate = options.replayGate; @@ -61,16 +56,11 @@ function layerGrokProviderAdapterRegistryReplay( const scriptPath = yield* path .fromFileUrl(new URL("../../../scripts/acp-replay-agent.ts", import.meta.url)) .pipe(Effect.orDie); - const adapter = makeGrokAdapterV2({ + const adapter = yield* makeGrokAdapterV2({ instanceId: GROK_DEFAULT_INSTANCE_ID, settings: DEFAULT_GROK_SETTINGS, environment: {}, hostPlatform, - childProcessSpawner, - crypto, - fileSystem, - idAllocator, - host, selfInvocation: yield* resolveSelfInvocation(), // Same wrapping as makeGrokAcpRuntime: client type and Ctrl+C cancel // metadata and the x.ai prompt-completion race, so replay sends what diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index f36d2c5959f5..1d10bb8e50e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -24,11 +24,9 @@ import { } from "@t3tools/contracts"; import { MuseSettings } from "@t3tools/provider-muse/settings"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { museInitializeParams, @@ -449,13 +447,10 @@ export function layer(input: { return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { return [ - makeMuseAdapterV2({ + yield* makeMuseAdapterV2({ instanceId: ProviderInstanceId.make(MUSE_PROVIDER_KIND), settings: DEFAULT_MUSE_SETTINGS, environment: input.environment ?? {}, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost, - fileSystem: yield* FileSystem.FileSystem, createHost: input.createHost, // Same queue the continuation worker drains when the fixture runs it. continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fa075fa28dfc..fe2f3f301a58 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -94,7 +94,6 @@ export const AntigravityDriver: ProviderDriver ModelManifest.manifestDefaultModel(manifest, DRIVER)), ); - const orchestrationAdapter = makeAntigravityAdapterV2({ + const orchestrationAdapter = yield* makeAntigravityAdapterV2({ instanceId, - crypto, - fileSystem, - path, - idAllocator, - host, selfInvocation, makeRuntime, withProcess: authFlow.withProcess, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 03d168c04a5b..65c168314fd1 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; import { type ChatAttachment, @@ -57,7 +56,7 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; -import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; @@ -480,10 +479,6 @@ export interface AcpAdapterV2SubagentUpdate { export interface AcpAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly flavor: AcpAdapterV2Flavor; - readonly crypto: Crypto.Crypto; - readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly host: ProviderHost.ProviderHostShape; /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ readonly selfInvocation: SelfInvocation; /** @@ -1490,10 +1485,14 @@ function shouldPersistToolUpdate( return persist; } -export function makeAcpAdapterV2( +export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( options: AcpAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { flavor, fileSystem, idAllocator, host, selfInvocation: self } = options; +) { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + const { flavor, selfInvocation: self } = options; const driver = flavor.driver; const continuationRequests = options.continuationRequests; const postSettleContinuationEnabled = @@ -5999,7 +5998,7 @@ export function makeAcpAdapterV2( const mcpBridge = yield* makeAcpMcpOverAcpBridge({ endpoint: mcpContext.endpoint, authorization: mcpContext.authorization, - allocateConnectionId: options.crypto.randomUUIDv4.pipe(Effect.orDie), + allocateConnectionId: crypto.randomUUIDv4.pipe(Effect.orDie), }); yield* Scope.addFinalizer(scope, mcpBridge.dispose); return mcpBridge; @@ -6019,7 +6018,7 @@ export function makeAcpAdapterV2( .makeRuntime(makeRuntimeInput(runtimeGeneration, threadId, resumeSessionId)) .pipe( Effect.provideService(Scope.Scope, runtimeScope), - Effect.provideService(Crypto.Crypto, options.crypto), + Effect.provideService(Crypto.Crypto, crypto), ); }); @@ -6096,7 +6095,7 @@ export function makeAcpAdapterV2( ) .pipe( Effect.provideService(Scope.Scope, replacementScope), - Effect.provideService(Crypto.Crypto, options.crypto), + Effect.provideService(Crypto.Crypto, crypto), ); // Session setup may publish commands before it returns. Buffer those // notifications, but do not expose request or extension handlers @@ -8049,6 +8048,6 @@ export function makeAcpAdapterV2( ), ), }); -} +}); export type AcpAdapterV2Env = FileSystem.FileSystem | IdAllocator.IdAllocatorV2; diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index a8509b01e130..9e889f1f816a 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -45,7 +45,7 @@ export interface ProviderCredentials { readonly remove: Effect.Effect; } -export interface ProviderHostShape { +interface ProviderHostService { readonly paths: ProviderHostPaths; readonly settings: { readonly get: Effect.Effect; @@ -68,6 +68,6 @@ export interface ProviderHostShape { ) => Effect.Effect; } -export class ProviderHost extends Context.Service()( +export class ProviderHost extends Context.Service()( "@t3tools/provider-core/server/ProviderHost", ) {} diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 76f7fda46954..58eae9fbbd31 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -99,7 +99,7 @@ const refreshedSnapshotSecond: ServerProvider = { /** A host whose settings never change and whose background demand is fixed. */ function layerProviderHost(input: { readonly runBackgroundWork: boolean; - readonly settings?: Pick["settings"]; + readonly settings?: Pick["settings"]; }) { return Layer.succeed( ProviderHost.ProviderHost, @@ -283,7 +283,7 @@ describe("makeManagedServerProvider", () => { }; const serverSettingsRef = yield* Ref.make(initialServerSettings); const serverSettingsChanges = yield* PubSub.unbounded(); - const hostSettings: ProviderHost.ProviderHostShape["settings"] = { + const hostSettings: ProviderHost.ProviderHost["Service"]["settings"] = { get: Ref.get(serverSettingsRef), changes: Stream.empty, subscribe: PubSub.subscribe(serverSettingsChanges).pipe( diff --git a/packages/provider-core/src/server/snapshotSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts index 5a1d207b15ee..f434d35b967e 100644 --- a/packages/provider-core/src/server/snapshotSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -29,7 +29,7 @@ export function haveProviderSnapshotSettingsChanged( export function makeProviderSnapshotSettingsSource( provider: Settings, - settings: ProviderHost.ProviderHostShape["settings"], + settings: ProviderHost.ProviderHost["Service"]["settings"], ): { readonly getSettings: Effect.Effect, ServerSettingsError>; readonly streamSettings: Stream.Stream>; diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 730cbb7649f8..5292d782e50d 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -22,7 +22,6 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -51,7 +50,6 @@ describe("CursorAdapterV2", () => { ({ status, model, lateModel }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-lifecycle-", }); @@ -63,17 +61,12 @@ describe("CursorAdapterV2", () => { interactionMode: "default", cwd: workspace, }); - const adapter = makeCursorAdapterV2({ + const adapter = yield* makeCursorAdapterV2({ instanceId, settings: yield* decodeCursorSettings({}), environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost.pipe( - Effect.provide(layerTestProviderHost({ cwd: workspace })), - ), - runner: { + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { assertComplete: Effect.void, open: () => Effect.succeed({ @@ -131,8 +124,9 @@ describe("CursorAdapterV2", () => { }; }), }), - }, - }); + }), + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ); const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), @@ -211,7 +205,6 @@ describe("CursorAdapterV2", () => { it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); const sentMessages: Array = []; let sendCalls = 0; @@ -223,17 +216,12 @@ describe("CursorAdapterV2", () => { interactionMode: "default", cwd: workspace, }); - const adapter = makeCursorAdapterV2({ + const adapter = yield* makeCursorAdapterV2({ instanceId, settings: yield* decodeCursorSettings({}), environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost.pipe( - Effect.provide(layerTestProviderHost({ cwd: workspace })), - ), - runner: { + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { assertComplete: Effect.void, open: () => Effect.succeed({ @@ -264,8 +252,9 @@ describe("CursorAdapterV2", () => { }; }), }), - }, - }); + }), + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ); const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("cursor-error-session"), @@ -564,17 +553,12 @@ describe("CursorAdapterV2", () => { }, }, ]; - const adapter = makeCursorAdapterV2({ + const adapter = yield* makeCursorAdapterV2({ instanceId, settings: yield* decodeCursorSettings({}), environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost.pipe( - Effect.provide(layerTestProviderHost({ cwd: workspace })), - ), - runner: { + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { assertComplete: Effect.void, open: () => Effect.succeed({ @@ -602,8 +586,9 @@ describe("CursorAdapterV2", () => { }; }), }), - }, - }); + }), + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ); const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("cursor-search-session"), diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 33796270205a..5aa9e836bd73 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -844,17 +844,17 @@ export interface CursorAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: CursorSettings; readonly environment: NodeJS.ProcessEnv; - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; - readonly host: ProviderHost.ProviderHostShape; } -export function makeCursorAdapterV2( - adapterOptions: CursorAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { fileSystem, path, idAllocator, runner, host } = adapterOptions; +export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( + options: CursorAdapterV2Options, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const host = yield* ProviderHost.ProviderHost; + const adapterOptions = options; const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; return ProviderAdapter.ProviderAdapterV2.of({ @@ -2596,7 +2596,7 @@ export function makeCursorAdapterV2( ), ), }); -} +}); export type CursorAdapterV2DriverEnv = | CursorAgentSdk.CursorAgentSdkRunner @@ -2615,23 +2615,13 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< create: Effect.fn("CursorAdapterV2Driver.create")( function* (input: ProviderAdapterDriverCreateInput) { const hostEnvironment = yield* HostProcessEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const host = yield* ProviderHost.ProviderHost; - return makeCursorAdapterV2({ + return yield* makeCursorAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled, }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - fileSystem, - path, - idAllocator, - runner, - host, }); }, (effect, input) => @@ -2661,20 +2651,10 @@ const layer: Layer.Layer< ProviderAdapter.ProviderAdapterV2, Effect.gen(function* () { const hostEnvironment = yield* HostProcessEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const host = yield* ProviderHost.ProviderHost; - return makeCursorAdapterV2({ + return yield* makeCursorAdapterV2({ instanceId: CURSOR_DEFAULT_INSTANCE_ID, settings: DEFAULT_CURSOR_SETTINGS, environment: hostEnvironment, - fileSystem, - path, - idAllocator, - runner, - host, }); }), ); diff --git a/packages/provider-cursor/src/server/credentialStore.test.ts b/packages/provider-cursor/src/server/credentialStore.test.ts index 5e176b661c46..803324d9f979 100644 --- a/packages/provider-cursor/src/server/credentialStore.test.ts +++ b/packages/provider-cursor/src/server/credentialStore.test.ts @@ -4,7 +4,6 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Option from "effect/Option"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 757e27bceef5..b25faa9db4e7 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -112,12 +112,7 @@ export interface GrokAdapterV2Options { readonly settings: GrokSettings; readonly environment: NodeJS.ProcessEnv; readonly hostPlatform: NodeJS.Platform; - readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly crypto: Crypto.Crypto; readonly selfInvocation: SelfInvocation; - readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly host: ProviderHost.ProviderHostShape; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly continuationRequests?: Parameters[0]["continuationRequests"]; readonly testHooks?: Parameters[0]["testHooks"]; @@ -229,7 +224,12 @@ export function grokLaunchRuntimeMode( : "approval-required"; } -export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor { +/** The flavor runs Grok's launcher through the adapter's spawner. */ +export function makeGrokAcpAdapterFlavor( + options: GrokAdapterV2Options & { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + }, +): AcpAdapterV2Flavor { return { driver: GROK_PROVIDER, runtimeHarness: "Grok", @@ -329,15 +329,14 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap }; } -export function makeGrokAdapterV2(options: GrokAdapterV2Options) { - const flavor = makeGrokAcpAdapterFlavor(options); - return makeAcpAdapterV2({ +export const makeGrokAdapterV2 = Effect.fn("makeGrokAdapterV2")(function* ( + options: GrokAdapterV2Options, +) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const flavor = makeGrokAcpAdapterFlavor({ ...options, childProcessSpawner }); + return yield* makeAcpAdapterV2({ instanceId: options.instanceId, flavor, - crypto: options.crypto, - fileSystem: options.fileSystem, - idAllocator: options.idAllocator, - host: options.host, selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined @@ -345,7 +344,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { : { continuationRequests: options.continuationRequests }), ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }), }); -} +}); export type GrokAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner @@ -365,24 +364,14 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver @@ -424,24 +413,14 @@ const layer: Layer.Layer< const hostEnvironment = yield* HostProcessEnvironment; const hostPlatform = yield* HostProcessPlatform; const selfInvocation = yield* resolveSelfInvocation(); - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const host = yield* ProviderHost.ProviderHost; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); - return makeGrokAdapterV2({ + return yield* makeGrokAdapterV2({ instanceId: GROK_DEFAULT_INSTANCE_ID, settings: DEFAULT_GROK_SETTINGS, environment: hostEnvironment, hostPlatform, - childProcessSpawner, - crypto, - fileSystem, - idAllocator, - host, selfInvocation, continuationRequests, nativeLogging: (threadId) => diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index c73dbb4e2297..33191bb8e46e 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -24,14 +24,12 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import type { MuseItem } from "./protocol.ts"; @@ -195,13 +193,10 @@ const makeHarness = Effect.fnUntraced(function* ( > = {}, ) { let hostCount = 0; - const adapter = makeMuseAdapterV2({ + const adapter = yield* makeMuseAdapterV2({ instanceId, settings: museSettings, environment: { PATH: "/fake/bin" }, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, - fileSystem: yield* FileSystem.FileSystem, createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), ...overrides, }); @@ -434,13 +429,10 @@ describe("MuseAdapterV2", () => { for (const kind of ["sessionNotFound", "notFound"] as const) { const fake = yield* makeFakeMuse(); const allocated = yield* preallocatedProviderThread(); - const adapter = makeMuseAdapterV2({ + const adapter = yield* makeMuseAdapterV2({ instanceId: INSTANCE_ID, settings: museSettings, environment: { PATH: "/fake/bin" }, - idAllocator: yield* IdAllocator.IdAllocatorV2, - host: yield* ProviderHost.ProviderHost, - fileSystem: yield* FileSystem.FileSystem, createHost: async () => fake.host, }); const runtime = yield* adapter.openSession({ diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 686fb64f1dc8..c834e1370ebe 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -35,7 +35,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; @@ -181,9 +181,6 @@ export interface MuseAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: MuseSettings; readonly environment: NodeJS.ProcessEnv; - readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly host: ProviderHost.ProviderHostShape; - readonly fileSystem: FileSystem.FileSystem; readonly modelCatalog?: Effect.Effect>; readonly createHost?: typeof createMuseSdkHost; readonly requestTimeoutMs?: number; @@ -245,10 +242,13 @@ const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "wo const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); /** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */ -export function makeMuseAdapterV2( +export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( options: MuseAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { idAllocator } = options; +) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const providerHost = yield* ProviderHost.ProviderHost; + const fileSystem = yield* FileSystem.FileSystem; + const protocolError = (detail: string, payload?: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ driver: MUSE_PROVIDER, @@ -269,8 +269,8 @@ export function makeMuseAdapterV2( const scope = yield* Effect.scope; // Muse rejects non-canonical workspace roots (for example macOS /tmp) and // reports canonical paths in approvals, so resolve symlinks once here. - const requestedCwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; - const cwd = yield* options.fileSystem + const requestedCwd = input.runtimePolicy.cwd ?? providerHost.paths.cwd; + const cwd = yield* fileSystem .realPath(requestedCwd) .pipe(Effect.orElseSucceed(() => requestedCwd)); const now = yield* DateTime.now; @@ -1581,14 +1581,14 @@ export function makeMuseAdapterV2( const text = providerMessageTextWithAttachmentPaths({ text: message.text, attachments: message.attachments, - resolveAttachmentPath: options.host.resolveAttachmentPath, + resolveAttachmentPath: providerHost.resolveAttachmentPath, }); if (text) parts.push({ type: "text", text }); for (const attachment of message.attachments) if (isProviderNativeImageAttachment(attachment)) { - const path = options.host.resolveAttachmentPath(attachment); + const path = providerHost.resolveAttachmentPath(attachment); if (!path) return yield* protocolError("Muse image attachment is missing"); - const bytes = yield* options.fileSystem + const bytes = yield* fileSystem .readFile(path) .pipe(Effect.mapError((cause) => protocolError("Cannot read Muse image", cause))); parts.push({ @@ -2031,4 +2031,4 @@ export function makeMuseAdapterV2( return runtime; }), }); -} +}); diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 8b9b5b1fa967..805749f7ef8c 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -63,7 +63,6 @@ export const MuseDriver: ProviderDriver = { const host = yield* ProviderHost.ProviderHost; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const { cwd } = host.paths; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const hostEnvironment = yield* HostProcessEnvironment; // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. @@ -148,13 +147,10 @@ export const MuseDriver: ProviderDriver = { ), ); const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); - const orchestrationAdapter = makeMuseAdapterV2({ + const orchestrationAdapter = yield* makeMuseAdapterV2({ instanceId, settings: effectiveConfig, environment: processEnvironment, - idAllocator, - host, - fileSystem, modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), continuationRequests, diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 9f80cdbead98..a6967c8ec9d8 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -34,7 +34,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import type * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { @@ -148,7 +148,6 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct nativeSessionId: string, client: object, ) { - const idAllocator = yield* IdAllocator.IdAllocatorV2; const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { @@ -157,20 +156,20 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct options: [], }; const policy = runtimePolicy("full-access", { cwd: "/workspace" }); - const adapter = makeOpenCodeAdapterV2({ + const adapter = yield* makeOpenCodeAdapterV2({ instanceId, settings: OPEN_CODE_TEST_SETTINGS, environment: {}, - runtime: { + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, - idAllocator, - host: { + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape), + Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, resolveAttachmentPath: () => null, - } as unknown as ProviderHost.ProviderHostShape, - }); + } as unknown as ProviderHost.ProviderHost["Service"]), + ); const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make(`session-opencode-${suffix}`), @@ -1421,7 +1420,6 @@ describe("OpenCodeAdapterV2", () => { it.effect("keeps a newly admitted prompt alive across stale idle and delayed busy evidence", () => Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; const nativeEvents = asyncEventStream(); const prompt = promiseGate(); const promptStarted = promiseGate(); @@ -1488,21 +1486,21 @@ describe("OpenCodeAdapterV2", () => { }, mcp: { add: async () => ({ data: true }) }, }; - const adapter = makeOpenCodeAdapterV2({ + const adapter = yield* makeOpenCodeAdapterV2({ instanceId: ProviderInstanceId.make("opencode-test"), settings: OPEN_CODE_TEST_SETTINGS, environment: {}, - runtime: { + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, - idAllocator, - host: { + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape), + Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, resolveAttachmentPath: () => null, - } as unknown as ProviderHost.ProviderHostShape, - }); + } as unknown as ProviderHost.ProviderHost["Service"]), + ); const threadId = ThreadId.make("thread-opencode-admission-race"); const providerSessionId = ProviderSessionId.make("session-opencode-admission-race"); const modelSelection = { @@ -1739,7 +1737,6 @@ describe("OpenCodeAdapterV2", () => { it.effect("interrupts an initial prompt while its SDK request is pending", () => Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; const nativeEvents = asyncEventStream(); const promptStarted = promiseGate(); const abortCalled = promiseGate(); @@ -1783,21 +1780,21 @@ describe("OpenCodeAdapterV2", () => { }, mcp: { add: async () => ({ data: true }) }, }; - const adapter = makeOpenCodeAdapterV2({ + const adapter = yield* makeOpenCodeAdapterV2({ instanceId: ProviderInstanceId.make("opencode-initial-stop-test"), settings: OPEN_CODE_TEST_SETTINGS, environment: {}, - runtime: { + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, { connectToOpenCodeServer: () => Effect.succeed({ url: "http://test.invalid", external: true }), createOpenCodeSdkClient: () => client, - } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape, - idAllocator, - host: { + } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape), + Effect.provideService(ProviderHost.ProviderHost, { paths: { cwd: "/workspace" }, resolveAttachmentPath: () => null, - } as unknown as ProviderHost.ProviderHostShape, - }); + } as unknown as ProviderHost.ProviderHost["Service"]), + ); const threadId = ThreadId.make("thread-opencode-initial-stop"); const providerSessionId = ProviderSessionId.make("session-opencode-initial-stop"); const modelSelection = { @@ -2419,8 +2416,6 @@ describe("OpenCodeAdapterV2", () => { it.effect("adopts the handed-over provider thread identity on session create", () => Effect.gen(function* () { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost.ProviderHost; let createCount = 0; const createInputs: Array = []; const fakeClient = { @@ -2467,14 +2462,11 @@ describe("OpenCodeAdapterV2", () => { const instanceId = ProviderInstanceId.make("opencode"); const threadId = ThreadId.make("thread-opencode-adopt"); const modelSelection = { instanceId, model: "default" }; - const adapter = makeOpenCodeAdapterV2({ + const adapter = yield* makeOpenCodeAdapterV2({ instanceId, settings: OPENCODE_TEST_SETTINGS, environment: {}, - runtime, - idAllocator, - host, - }); + }).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); const session = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("provider-session-opencode-adopt"), diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 0fa85f3be6d4..8c9f5e83ae6c 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -438,9 +438,6 @@ export interface OpenCodeAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: OpenCodeSettings; readonly environment: NodeJS.ProcessEnv; - readonly runtime: OpenCodeRuntime.OpenCodeRuntimeShape; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly host: ProviderHost.ProviderHostShape; readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; } @@ -944,10 +941,12 @@ function unwrapData(operation: string, result: { readonly data?: A }): NonNul return result.data as NonNullable; } -export function makeOpenCodeAdapterV2( +export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function* ( options: OpenCodeAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { idAllocator, runtime, host } = options; +) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; return ProviderAdapter.ProviderAdapterV2.of({ instanceId: options.instanceId, @@ -3724,7 +3723,7 @@ export function makeOpenCodeAdapterV2( ), ), }); -} +}); export type OpenCodeAdapterV2DriverEnv = | OpenCodeRuntime.OpenCodeRuntime @@ -3742,17 +3741,11 @@ export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< create: Effect.fn("OpenCodeAdapterV2Driver.create")( function* (input: ProviderAdapterDriverCreateInput) { const hostEnvironment = yield* HostProcessEnvironment; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const host = yield* ProviderHost.ProviderHost; - return makeOpenCodeAdapterV2({ + return yield* makeOpenCodeAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - runtime: openCodeRuntime, - idAllocator, - host, ...(providerEventLoggers.native === undefined ? {} : { nativeEventLogger: providerEventLoggers.native }), @@ -3778,17 +3771,11 @@ const layer: Layer.Layer, - resolveAttachmentPath: ProviderHost.ProviderHostShape["resolveAttachmentPath"], + resolveAttachmentPath: ProviderHost.ProviderHost["Service"]["resolveAttachmentPath"], ) => Effect.gen(function* () { const { client } = connection; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 19a32596ee0e..ff9486cb854c 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -22,7 +22,6 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; @@ -31,21 +30,14 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; -import { - makePiAdapterV2, - PiAdapterV2Driver, - PI_PROVIDER, - type PiAdapterV2Options, -} from "./adapter.ts"; +import { makePiAdapterV2, PI_PROVIDER, type PiAdapterV2Options } from "./adapter.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; const layerTest = Layer.mergeAll( @@ -326,9 +318,6 @@ const makeAdapter = Effect.fnUntraced(function* ( forkFake?: FakePi, continuationRequests?: PiAdapterV2Options["continuationRequests"], ) { - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost.ProviderHost; - const fileSystem = yield* FileSystem.FileSystem; const spawner = forkFake === undefined ? fake.spawner @@ -337,31 +326,12 @@ const makeAdapter = Effect.fnUntraced(function* ( ? forkFake.spawner.spawn(command) : fake.spawner.spawn(command), ); - if (continuationRequests !== undefined) { - return yield* PiAdapterV2Driver.create({ - instanceId: PI_INSTANCE_ID, - displayName: undefined, - enabled: true, - environment: [], - config: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HostProcessEnvironment, {}), - Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { - ...continuationRequests, - take: Effect.never, - }), - ); - } - return makePiAdapterV2({ + return yield* makePiAdapterV2({ instanceId: PI_INSTANCE_ID, settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, environment: {}, - spawner, - fileSystem, - idAllocator, - host, - }); + ...(continuationRequests === undefined ? {} : { continuationRequests }), + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); const openRuntime = Effect.fnUntraced(function* ( diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 06f702c7eb31..f27fead50605 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -228,10 +228,10 @@ export interface PiAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: PiSettings; readonly environment: NodeJS.ProcessEnv; - readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly host: ProviderHost.ProviderHostShape; + /** + * Where to offer a wake turn for provider-initiated work. Without one, such + * work has no owner and the adapter stops it. + */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -386,10 +386,14 @@ interface PiWake { // ── adapter ─────────────────────────────────────────────────── -export function makePiAdapterV2( +export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( options: PiAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { idAllocator } = options; +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + const { continuationRequests } = options; const protocolError = (detail: string, payload?: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ @@ -407,11 +411,11 @@ export function makePiAdapterV2( input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, ) { const scope = yield* Effect.scope; - const cwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const provideCacheFs = (effect: Effect.Effect) => effect.pipe( - Effect.provideService(FileSystem.FileSystem, options.fileSystem), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterOpenSessionError({ @@ -425,7 +429,7 @@ export function makePiAdapterV2( // hook. Materialize it even when this session has no MCP credential so // Supervised never silently degrades to unrestricted tool execution. const extensionPath = yield* provideCacheFs( - materializePiT3McpExtension(options.host.paths.providerStatusCacheDir), + materializePiT3McpExtension(host.paths.providerStatusCacheDir), ); const resolvedLaunchArgs = resolvePiLaunchArgs(options.settings.launchArgs); if (!resolvedLaunchArgs.ok) { @@ -444,7 +448,7 @@ export function makePiAdapterV2( cwd, env: launch.env, }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( (cause) => new ProviderAdapter.ProviderAdapterOpenSessionError({ @@ -1622,7 +1626,7 @@ export function makePiAdapterV2( if ( turn === null && state?.providerThread.appThreadId != null && - options.continuationRequests !== undefined && + continuationRequests !== undefined && !closed && !stopRequested ) { @@ -1633,7 +1637,7 @@ export function makePiAdapterV2( return; } yield* updateProviderSession("running", null); - yield* options.continuationRequests + yield* continuationRequests .offer({ threadId: state.providerThread.appThreadId, providerThreadId: state.providerThread.id, @@ -2337,10 +2341,10 @@ export function makePiAdapterV2( const images: Array<{ type: "image"; data: string; mimeType: string }> = []; const extraLines: Array = []; for (const attachment of attachments) { - const path = options.host.resolveAttachmentPath(attachment); + const path = host.resolveAttachmentPath(attachment); if (path === null) continue; if (attachment.mimeType.startsWith("image/")) { - const bytes = yield* options.fileSystem.readFile(path); + const bytes = yield* fileSystem.readFile(path); images.push({ type: "image", data: Buffer.from(bytes).toString("base64"), @@ -3033,7 +3037,7 @@ export function makePiAdapterV2( } return file; }), - ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner)); + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); const now = yield* DateTime.now; return yield* registerThread( { @@ -3082,7 +3086,7 @@ export function makePiAdapterV2( return runtime; }), }); -} +}); /** * Resolve the pi session-tree entry `fork` should re-root at for a rollback. @@ -3210,19 +3214,11 @@ export const PiAdapterV2Driver: ProviderAdapterDriver) { const hostEnvironment = yield* HostProcessEnvironment; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const host = yield* ProviderHost.ProviderHost; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - return makePiAdapterV2({ + return yield* makePiAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - spawner, - fileSystem, - idAllocator, - host, continuationRequests, }); }, @@ -3246,19 +3242,11 @@ const layer: Layer.Layer Date: Thu, 8 Oct 2026 22:04:47 -0700 Subject: [PATCH 052/377] fix(web): show a row spinner instead of a banner when expanding a folder (#17378) --- .../src/components/files/FileBrowserPanel.tsx | 18 +++++++++++++++++- .../components/files/useDirectoryEntries.ts | 13 ++++++++++++- apps/web/src/pierre-icons.ts | 4 ++++ apps/web/src/pierre-tree-theme.ts | 5 +++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ad134abf8904..d9722303ab72 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -115,6 +115,7 @@ export default function FileBrowserPanel({ ready, error, isPending, + loadingDirectories, } = useDirectoryEntries(environmentId, cwd); const [query, setQuery] = useState(""); const [expandAll, setExpandAll] = useState(false); @@ -239,6 +240,9 @@ export default function FileBrowserPanel({ showEntryContextMenuRef.current = showEntryContextMenu; }); + // The tree reads decorations at render time; a folder still loading its + // children shows a spinner in its row instead of a banner that shifts the tree. + const loadingDirectoriesRef = useRef(loadingDirectories); const treeModelRef = useRef["model"] | null>(null); const dragMention = useMemo( () => @@ -283,10 +287,22 @@ export default function FileBrowserPanel({ } }, paths: [], + renderRowDecoration: ({ item, row }) => + row.kind === "directory" && + row.isExpanded && + loadingDirectoriesRef.current.has(item.path.replace(/\/$/, "")) + ? { icon: "t3-tree-icon-loading", title: "Loading…" } + : null, search: false, onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); + useEffect(() => { + if (loadingDirectoriesRef.current === loadingDirectories) return; + loadingDirectoriesRef.current = loadingDirectories; + // Re-render the rows with the current options so decorations update. + model.setComposition(model.getComposition()); + }, [loadingDirectories, model]); const search = useFileTreeSearch(model); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, directoryPaths), @@ -542,7 +558,7 @@ export default function FileBrowserPanel({ More matches available. Refine your search.
) : null} - {(isPending || pathSearch.isPending) && ( + {(!ready || pathSearch.isPending) && (
Loading files…
diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index c7a1db0207cb..55aa163ee62d 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const [directories, setDirectories] = useState(new Map()); const [errors, setErrors] = useState(new Map()); const [pending, setPending] = useState(0); + // First loads only: a refresh keeps showing the cached children, so it gets no row spinner. + const [loadingDirectories, setLoadingDirectories] = useState>(new Set()); const requests = useRef(new Map>()); const loaded = useRef(new Set()); const requested = useRef(new Set()); @@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { requested.current.add(directoryPath); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); setPending((count) => count + 1); + if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); const request = (async () => { if (running.current >= 4) await new Promise((resolve) => waiting.current.push(resolve)); @@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { }) .finally(() => { requests.current.delete(directoryPath); - if (active.current) setPending((count) => count - 1); + if (!active.current) return; + setPending((count) => count - 1); + if (!refresh) + setLoadingDirectories((previous) => { + const next = new Set(previous); + next.delete(directoryPath); + return next; + }); }); requests.current.set(directoryPath, request); return request; @@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { load, refresh, isPending: pending > 0, + loadingDirectories, ready: directories.has(""), error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, }; diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index ead46e89d6ee..d789bd6dfcd4 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -28,6 +28,10 @@ const T3_FILE_ICON_SPRITE = ` + + + + diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 1c7bd7f2d53b..906b34ba1c51 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -11,6 +11,11 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-font-size-override: 12px; } button[data-type='item'] { border-radius: 5px; } + svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } + @media (prefers-reduced-motion: no-preference) { + svg[data-icon-name='t3-tree-icon-loading'] { animation: t3-tree-spin 1s linear infinite; } + } + @keyframes t3-tree-spin { to { transform: rotate(360deg); } } `; /** Host styles that keep a Pierre tree on the active color scheme and foreground. */ From 8f760371978f05b49e6f6b6aa4b2c523ab14b4fe Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Fri, 9 Oct 2026 01:10:34 -0400 Subject: [PATCH 053/377] fix(server): a timed-out browser drag no longer exits the server (#17360) Co-authored-by: Claude Opus 5.5 --- .../src/preview/ServerBrowserPage.test.ts | 42 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 11 +++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 291f89e125eb..9a4775bb4717 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -277,3 +277,45 @@ describe("server browser element refs", () => { expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2); }); }); + +describe("server browser drag", () => { + it("fails the drag, not the server, when it times out while the cursor is still moving", async () => { + const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded."); + const box = { x: 0, y: 0, width: 20, height: 20 }; + const targetBox = Promise.withResolvers(); + // Only the calls `drag` makes. The drag fails at once; the target's position arrives only + // when the test hands it over, after the rejection has had a turn to go unobserved. + const page = { + locator: (selector: string) => + selector === "#card" + ? { + scrollIntoViewIfNeeded: async () => {}, + boundingBox: async () => box, + dragTo: () => Promise.reject(timedOut), + } + : { boundingBox: () => targetBox.promise }, + } as unknown as Page; + const unobserved: Array = []; + const onUnhandled = (reason: unknown) => unobserved.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + let finished = false; + const settled = ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }).then( + () => null, + (error: unknown) => error, + ); + void settled.then(() => (finished = true)); + // Two macrotask turns: Node reports a rejection nobody observed after the first. + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + // The failed drag waits for the cursor to reach the target before it reports. + expect(finished).toBe(false); + targetBox.resolve(box); + + expect(await settled).toBe(timedOut); + expect(unobserved).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 4c2a36478e07..3df4901f469c 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -380,11 +380,16 @@ export const drag = async ( const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); - // The cursor travels with the drag; the page sees one continuous gesture. - const dropped = source.dragTo(target, { timeout }); + // The cursor travels with the drag; the page sees one continuous gesture. Observe the drag's + // outcome as it starts: a rejection nobody handles while the cursor moves exits the server. + const dragFailure = source.dragTo(target, { timeout }).then( + () => null, + (error: unknown) => ({ error }), + ); const end = await target.boundingBox({ timeout }).catch(() => null); if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move"); - await dropped; + const failure = await dragFailure; + if (failure) throw failure.error; }; /** Sets files on one file input; false when no locator or selector names one. */ From 563645cf6e104ad930f4c9b209678a77eef07e65 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 9 Oct 2026 01:12:46 -0400 Subject: [PATCH 054/377] fix(server): a logged-out Claude CLI no longer reports as authenticated (#15459) --- .../provider/ClaudeCapabilitiesProbe.test.ts | 1 + apps/server/src/provider/ClaudeProvider.ts | 54 +++++++++++ .../src/provider/ProviderRegistry.test.ts | 97 +++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 5d63060493d9..d617eb999fa6 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -279,6 +279,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth", + apiKeySource: undefined, apiProvider: undefined, slashCommands: [ { diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 01d731481c23..553d3d2e5fef 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -167,6 +167,38 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } +/** + * Whether the SDK's account payload evidences a credential the CLI can use. + * + * The capability probe resolves for a logged-out CLI, so a completed probe only + * proves Claude Code started. `tokenSource: "none"` is the CLI reporting it + * found no token at all, and is the one shape that disproves authentication. + * Everything else either names a credential or, on a third-party backend, omits + * these fields by design because auth lives with AWS or gcloud instead. + * + * Silence is deliberately not disproof. Profile-authenticated installs report no + * token source, and a CLI too old to send an account payload reports nothing at + * all; treating either as logged out would sign working setups out of Settings. + * `apiKeySource: "none"` means no API key is in use, so it is no evidence either. + */ +function claudeAuthStatus( + capabilities: Pick< + ClaudeCapabilitiesProbe, + "email" | "subscriptionType" | "tokenSource" | "apiKeySource" | "apiProvider" + >, +): "authenticated" | "unauthenticated" { + if (capabilities.apiProvider !== undefined && capabilities.apiProvider !== "firstParty") { + return "authenticated"; + } + if (capabilities.tokenSource !== "none") return "authenticated"; + // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated + // all the same, so the key and account fields still get a say. + const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; + return hasApiKey || capabilities.email || capabilities.subscriptionType + ? "authenticated" + : "unauthenticated"; +} + // ── SDK capability probe ──────────────────────────────────────────── // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the @@ -232,6 +264,8 @@ type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** Where the CLI found an API key, when it authenticates with one. */ + readonly apiKeySource: string | undefined; /** * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) @@ -387,6 +421,7 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiKeySource?: string; readonly apiProvider?: string; } | undefined; @@ -394,6 +429,7 @@ const probeClaudeCapabilities = ( email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), ...(usage ? { usage } : {}), @@ -590,6 +626,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + if (claudeAuthStatus(capabilities) === "unauthenticated") { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unauthenticated" }, + message: "Claude Code is not authenticated. Run `claude auth login` and try again.", + }, + }); + } + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index b76e079fc38a..8653bd32ebd2 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -164,6 +164,7 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiKeySource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; }; @@ -174,6 +175,7 @@ function claudeCapabilities(overrides: Partial = {}) { email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiKeySource: undefined, apiProvider: undefined, slashCommands: [], ...overrides, @@ -3167,6 +3169,100 @@ it.layer( ), ); + it.effect("reports a logged-out CLI as unauthenticated", () => + Effect.gen(function* () { + // The capability probe resolves for a logged-out CLI, so `tokenSource: + // "none"` is the only thing separating it from an authenticated one. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "none", + apiProvider: "firstParty", + }), + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps an API key install authenticated when it reports no token source", () => + Effect.gen(function* () { + // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that + // field alone would log the install out. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "ANTHROPIC_API_KEY", + apiProvider: "firstParty", + }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps a third-party backend authenticated without any token source", () => + Effect.gen(function* () { + // Bedrock and Vertex authenticate outside the CLI, so the account + // payload is empty by design rather than because nobody logged in. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ tokenSource: "none", apiProvider: "bedrock" }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps a CLI that says nothing about its account authenticated", () => + Effect.gen(function* () { + // Profile-authenticated installs report no token source at all, and a + // CLI too old to send an account payload reports nothing whatsoever. + // Only `tokenSource: "none"` disproves authentication; saying nothing + // is not the same as saying no. + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -3204,6 +3300,7 @@ it.layer( email: undefined, subscriptionType: undefined, tokenSource: undefined, + apiKeySource: undefined, apiProvider: undefined, slashCommands: [], usage: { rate_limits_available: true, rate_limits: {} }, From 3b6af0bd1600f034b0466e1b8ff017fbabeba910 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:45:58 +0200 Subject: [PATCH 055/377] fix(server): Pi loads every selected skill without losing prompt text (#17194) --- .../message_steering/pi_transcript.ndjson | 2 - .../fixtures/multi_turn/pi_transcript.ndjson | 2 - .../pi_compaction/pi_transcript.ndjson | 2 - .../pi_transcript.ndjson | 4 - .../fixtures/simple/pi_transcript.ndjson | 2 - .../thread_rollback/pi_transcript.ndjson | 2 - .../pi_transcript.ndjson | 4 - .../pi_transcript.ndjson | 2 - .../provider-pi/src/server/adapter.test.ts | 62 +------- packages/provider-pi/src/server/adapter.ts | 39 +---- .../provider-pi/src/server/commands.test.ts | 17 -- packages/provider-pi/src/server/commands.ts | 35 ---- .../src/server/mcpExtensionSource.test.ts | 149 +++++++++++++++++- .../src/server/mcpExtensionSource.ts | 36 ++++- 14 files changed, 185 insertions(+), 173 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_transcript.ndjson index 092ab40ad110..32ffdd3987c4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"message_steering","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/pi_transcript.ndjson index 2e3fd92409bf..7e32afc0a5b2 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"multi_turn","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/pi_transcript.ndjson index ff20ad15dab3..43aadfd2d881 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/pi_compaction/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"pi_compaction","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/pi_transcript.ndjson index 06a20a5ceec4..cba3bd8a396d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"provider_thread_resume","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} @@ -61,9 +59,7 @@ {"type":"emit_inbound","label":"response:get_session_stats","frame":{"id":"t3-8","type":"response","command":"get_session_stats","success":true,"data":{"sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","userMessages":1,"assistantMessages":1,"toolCalls":0,"toolResults":0,"totalMessages":3,"tokens":{"input":1792,"output":85,"cacheRead":0,"cacheWrite":0,"total":1877},"cost":0.00008212932000000001,"contextUsage":{"tokens":1877,"contextWindow":1048576,"percent":0.17900466918945312}}}} {"type":"expect_outbound","label":"process_start@p2","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"switch_session@p2","frame":{"type":"switch_session","sessionPath":"/pi-sessions/session-1.jsonl","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands@p2","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000003","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} -{"type":"emit_inbound","label":"response:get_commands@p2","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000004","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000005","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:switch_session@p2","frame":{"id":"t3-0","type":"response","command":"switch_session","success":true,"data":{"cancelled":false}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_transcript.ndjson index 231a1cfdb08d..ba7f84de2878 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"simple","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/pi_transcript.ndjson index f93112215952..2fc3def568e6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/pi_transcript.ndjson @@ -1,11 +1,9 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"thread_rollback","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} {"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-3","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"model_change","id":"648fe89a","parentId":null,"timestamp":"2026-01-01T00:00:00.000Z","provider":"openrouter","modelId":"deepseek/deepseek-v4-flash"},{"type":"thinking_level_change","id":"00c31b78","parentId":"648fe89a","timestamp":"2026-01-01T00:00:00.000Z","thinkingLevel":"high"}],"leafId":"00c31b78"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson index 4bee5483ad42..2d92c45b2adb 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"thread_rollback_after_stop","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} @@ -151,9 +149,7 @@ {"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-10","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"message","id":"ee73daae","parentId":"b184b5a8","timestamp":"2026-01-01T00:00:01.971Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969}},{"type":"message","id":"c3915df7","parentId":"ee73daae","timestamp":"2026-01-01T00:00:06.406Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"641f3d3f","parentId":"c3915df7","timestamp":"2026-01-01T00:00:06.463Z","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}},{"type":"message","id":"e3ab1495","parentId":"641f3d3f","timestamp":"2026-01-01T00:00:06.465Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}}],"leafId":"e3ab1495"}}} {"type":"expect_outbound","label":"process_start@p2","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"switch_session@p2","frame":{"type":"switch_session","sessionPath":"/pi-sessions/session-1.jsonl","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands@p2","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000003","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} -{"type":"emit_inbound","label":"response:get_commands@p2","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000004","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000005","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:switch_session@p2","frame":{"id":"t3-0","type":"response","command":"switch_session","success":true,"data":{"cancelled":false}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson index 35e3e1d21e8d..14e710c1ff9d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson @@ -1,10 +1,8 @@ {"type":"transcript_start","provider":"pi","protocol":"pi.rpc-jsonl","version":"1.0.0","scenario":"turn_interrupt_mid_tool","metadata":{"generatedBy":"live-pi-recorder","piVersion":"1.0.0","model":"openrouter/deepseek/deepseek-v4-flash","launchArgs":"--provider openrouter --model deepseek/deepseek-v4-flash --no-extensions --no-skills --no-prompt-templates --no-context-files --approve"}} {"type":"expect_outbound","label":"process_start","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"get_state","frame":{"type":"get_state","id":"t3-0"}} -{"type":"expect_outbound","label":"get_commands","frame":{"type":"get_commands","id":"t3-1"}} {"type":"emit_inbound","label":"extension_ui_request","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000001","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} {"type":"emit_inbound","label":"response:get_state","frame":{"id":"t3-0","type":"response","command":"get_state","success":true,"data":{"model":{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}},"thinkingLevel":"high","isStreaming":false,"isCompacting":false,"steeringMode":"one-at-a-time","followUpMode":"one-at-a-time","sessionFile":"/pi-sessions/session-1.jsonl","sessionId":"00000000-0000-4000-8000-000000000002","autoCompactionEnabled":true,"messageCount":0,"pendingMessageCount":0}}} -{"type":"emit_inbound","label":"response:get_commands","frame":{"id":"t3-1","type":"response","command":"get_commands","success":true,"data":{"commands":[]}}} {"type":"expect_outbound","label":"get_available_models","frame":{"type":"get_available_models","id":"t3-2"}} {"type":"emit_inbound","label":"response:get_available_models","frame":{"id":"t3-2","type":"response","command":"get_available_models","success":true,"data":{"models":[{"type":"chat","id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash 0423","api":"openai-completions","baseUrl":"https://openrouter.ai/api/v1","provider":"openrouter","reasoning":true,"thinkingLevelMap":{"off":"none","minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null},"input":["text"],"cost":{"input":0.04186,"output":0.08372,"cacheRead":0.008372,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsDeveloperRole":false,"thinkingFormat":"openrouter","supportsStrictMode":true,"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true}}]}}} {"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","id":"t3-3"}} diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index ff9486cb854c..562fb5ca0790 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -94,10 +94,6 @@ interface FakePi { readonly allRequests: () => ReadonlyArray; /** Data returned by the next `get_session_stats` acks, consumed in order. */ readonly queueStats: (data: unknown) => void; - /** Data returned by the next `get_commands` acks, consumed in order. */ - readonly queueCommands: (data: unknown) => void; - /** Make the next `get_commands` ack fail. */ - readonly failNextCommands: () => void; /** Close the fake process stdout stream. */ readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { @@ -137,7 +133,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { const messagesQueue: Array = []; const stateQueue: Array> = []; const statsQueue: Array = []; - const commandsQueue: Array<{ readonly success: boolean; readonly data?: unknown }> = []; const allRequests: Array = []; let deferState = false; let deferredStateRequest: PiRpcRecord | undefined; @@ -191,8 +186,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; - case "get_commands": - return { ...base, ...(commandsQueue.shift() ?? { data: { commands: [] } }) }; case "fork": return { ...base, data: { text: "Hello pi", cancelled: false } }; default: @@ -305,8 +298,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { }, queueState: (data) => stateQueue.push(data), queueStats: (data) => statsQueue.push(data), - queueCommands: (data) => commandsQueue.push({ success: true, data }), - failNextCommands: () => commandsQueue.push({ success: false }), closeStdout: Queue.end(stdout), lastSpawn: () => lastSpawn, } satisfies FakePi; @@ -1697,22 +1688,9 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("expands a selected $ skill through Pi's native skill command", () => + it.effect("preserves selected skill references for the Pi extension to expand", () => Effect.gen(function* () { const fake = yield* makeFakePi; - fake.queueCommands({ - commands: [ - { - name: "skill:repo-review", - description: "Review this repository.", - source: "skill", - sourceInfo: { - path: "/workspace/.agents/skills/repo-review/SKILL.md", - scope: "project", - }, - }, - ], - }); const { runtime } = yield* openRuntime(fake); const providerThread = yield* runtime.ensureThread({ threadId: THREAD_ID, @@ -1728,43 +1706,7 @@ describe("PiAdapterV2", () => { "Review this change please $repo-review", ); const prompt = yield* fake.takeRequest("prompt"); - assert.equal(prompt["message"], "/skill:repo-review Review this change please"); - }).pipe(Effect.scoped, Effect.provide(layerTest)), - ); - - it.effect("expands every selected $ skill through Pi native skill commands", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - fake.queueCommands({ - commands: [ - { - name: "skill:repo-review", - source: "skill", - sourceInfo: { - path: "/workspace/.agents/skills/repo-review/SKILL.md", - scope: "project", - }, - }, - { - name: "skill:deploy", - source: "skill", - sourceInfo: { - path: "/workspace/.agents/skills/deploy/SKILL.md", - scope: "project", - }, - }, - ], - }); - const { runtime } = yield* openRuntime(fake); - const providerThread = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - - yield* startTurn(runtime, providerThread, "default", [], "use $repo-review and $deploy"); - const prompt = yield* fake.takeRequest("prompt"); - assert.equal(prompt["message"], "/skill:repo-review /skill:deploy use and"); + assert.equal(prompt["message"], "Review this change please $repo-review"); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index f27fead50605..d8dedd71d0b6 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -63,12 +63,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { - expandPiSkillReference, - parsePiCompactCommand, - parsePiDiscoveredCommands, - type PiCompactCommand, -} from "./commands.ts"; +import { parsePiCompactCommand, type PiCompactCommand } from "./commands.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -114,7 +109,6 @@ const STREAM_FLUSH_MS = 50; const PI_REQUEST_TIMEOUT_MS = 15_000; // Session lifecycle hooks reload extensions, MCP servers and language servers. const PI_SESSION_TIMEOUT_MS = 60_000; -const PI_SKILL_DISCOVERY_TIMEOUT_MS = 4_000; const PI_UNSOLICITED_ACTIVITY_ERROR = "Pi started agent work outside an active T3 turn. The session was stopped to prevent invisible tool execution."; const SETTLE_PROBE_MAX_ATTEMPTS = 3; @@ -458,15 +452,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }), ), ); - const discoverSkillNames = connection - .request({ type: "get_commands" }, PI_SKILL_DISCOVERY_TIMEOUT_MS) - .pipe( - Effect.map( - (data) => new Set(parsePiDiscoveredCommands(data).skills.map((skill) => skill.name)), - ), - ); - let skillNames: Set | null = null; - const now = yield* DateTime.now; let sessionEntity: OrchestrationV2ProviderSession = { id: input.providerSessionId, @@ -2117,16 +2102,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.forkIn(scope), ); - // Discovery can invoke extension code and therefore raise a blocking - // UI request. Start it only after the event pump exists, and never hold - // session opening on it; startup requests are persisted at session - // scope and can be answered before a turn begins. - yield* discoverSkillNames.pipe( - Effect.tap((discovered) => Effect.sync(() => (skillNames = discovered))), - Effect.ignore, - Effect.forkIn(scope), - ); - // ── session runtime ─────────────────────────────────── const registerThread = Effect.fnUntraced(function* ( @@ -2329,15 +2304,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( text: string, attachments: ReadonlyArray, ) { - // Provider discovery and the live session are separate Pi processes. - // Retry a failed session-local lookup once at first use so a transient - // startup failure cannot leave a visible $ skill inert for this session. - if (skillNames === null && text.includes("$")) { - skillNames = yield* discoverSkillNames.pipe( - Effect.orElseSucceed(() => new Set()), - ); - } - const expandedText = skillNames === null ? text : expandPiSkillReference(text, skillNames); const images: Array<{ type: "image"; data: string; mimeType: string }> = []; const extraLines: Array = []; for (const attachment of attachments) { @@ -2354,8 +2320,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( extraLines.push(`[Attachment saved at ${path}]`); } } - const message = - extraLines.length === 0 ? expandedText : `${expandedText}\n\n${extraLines.join("\n")}`; + const message = extraLines.length === 0 ? text : `${text}\n\n${extraLines.join("\n")}`; return { message, images }; }); diff --git a/packages/provider-pi/src/server/commands.test.ts b/packages/provider-pi/src/server/commands.test.ts index 09f39130dd06..ef7b247ee588 100644 --- a/packages/provider-pi/src/server/commands.test.ts +++ b/packages/provider-pi/src/server/commands.test.ts @@ -1,7 +1,6 @@ import { expect, it } from "@effect/vitest"; import { - expandPiSkillReference, parsePiCompactCommand, parsePiDiscoveredCommands, PI_COMPACT_SLASH_COMMAND, @@ -109,19 +108,3 @@ it("prepends the builtin compact command without duplicating a discovered one", ]), ).toEqual([PI_COMPACT_SLASH_COMMAND, { name: "hello" }]); }); - -it("leaves unrelated dollar-prefixed text unchanged", () => { - expect(expandPiSkillReference("Explain $HOME", new Set(["global-review"]))).toBe("Explain $HOME"); -}); - -it("hoists every known $ skill and keeps the rest of the prompt", () => { - expect(expandPiSkillReference("use $alpha then $beta please", new Set(["alpha", "beta"]))).toBe( - "/skill:alpha /skill:beta use then please", - ); -}); - -it("preserves code indentation and line breaks when expanding a skill", () => { - expect(expandPiSkillReference("$review\n```ts\n const x = 1;\n```", new Set(["review"]))).toBe( - "/skill:review ```ts\n const x = 1;\n```", - ); -}); diff --git a/packages/provider-pi/src/server/commands.ts b/packages/provider-pi/src/server/commands.ts index 3e02b7718a45..07b992dd7c27 100644 --- a/packages/provider-pi/src/server/commands.ts +++ b/packages/provider-pi/src/server/commands.ts @@ -91,41 +91,6 @@ export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { return { slashCommands, skills }; } -/** - * Pi expands skills only through leading `/skill:name` commands. T3 stores - * skill chips as `$name`, so hoist every known `$skill` to that native - * command position while preserving the rest of the user's prompt. - */ -export function expandPiSkillReference(text: string, skillNames: ReadonlySet): string { - const references = /(^|\s)\$([^\s]+)(?=\s|$)/g; - const found: Array<{ name: string; start: number; end: number }> = []; - for (const match of text.matchAll(references)) { - const name = match[2]; - if (name === undefined || !skillNames.has(name) || match.index === undefined) continue; - const tokenStart = match.index + (match[1]?.length ?? 0); - found.push({ name, start: tokenStart, end: tokenStart + name.length + 1 }); - } - if (found.length === 0) return text; - - const orderedNames: string[] = []; - const seen = new Set(); - for (const token of found) { - if (seen.has(token.name)) continue; - seen.add(token.name); - orderedNames.push(token.name); - } - - let body = text; - for (let index = found.length - 1; index >= 0; index -= 1) { - const token = found[index]; - if (token === undefined) continue; - body = `${body.slice(0, token.start)}${body.slice(token.end)}`; - } - body = body.trim(); - const prefix = orderedNames.map((name) => `/skill:${name}`).join(" "); - return body.length === 0 ? prefix : `${prefix} ${body}`; -} - function recordField(input: unknown, key: string): unknown { return Predicate.isObject(input) ? input[key] : undefined; } diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index 4d4aef33d8fd..25b040c2c002 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Pi extensions run outside Effect; these tests exercise their native filesystem boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; import * as NodeModule from "node:module"; import * as NodeVM from "node:vm"; import { assert, describe, it } from "@effect/vitest"; @@ -9,6 +13,40 @@ type RequestHook = ( ctx: { model: { provider: string } }, ) => Record | undefined; +interface SkillCommand { + readonly name: string; + readonly source: string; + readonly sourceInfo: { readonly path: string }; +} + +type InputHook = ( + event: { text: string; images?: ReadonlyArray }, + ctx: { ui: { notify: (message: string, level: string) => void } }, +) => Promise<{ action: string; text: string; images?: ReadonlyArray } | undefined>; + +async function loadHooks(commands: ReadonlyArray = []) { + const handlers = new Map(); + // Execute the shipped extension with MCP disabled; this path needs no Typebox. + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { env: {} }, + NodeFSP, + NodePath, + // Frontmatter parsing belongs to Pi. These fixtures contain only skill bodies. + stripFrontmatter: (content: string) => content, + pi: { + on: (name: string, handler: unknown) => handlers.set(name, handler), + getCommands: () => commands, + }, + }); + return handlers; +} + interface RegisteredTool { readonly name: string; readonly description: string; @@ -55,7 +93,7 @@ async function loadMcpBridge( { name: "preview_snapshot", description: "Inspect the collaborative browser." }, ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); const source = NodeModule.stripTypeScriptTypes( - PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", "async function", ), @@ -271,7 +309,7 @@ describe("Pi tool discovery permissions", () => { let toolCall: ToolCallHook | undefined; let searchPath = "builtin:tool-search"; const source = NodeModule.stripTypeScriptTypes( - PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", "async function", ), @@ -315,7 +353,7 @@ async function loadRequestHook(): Promise { const handlers = new Map(); // Execute the shipped extension with MCP disabled; this path needs no Typebox. const source = NodeModule.stripTypeScriptTypes( - PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( + PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", "async function", ), @@ -359,3 +397,108 @@ describe("Pi upstream output-budget workaround", () => { ); }); }); + +async function loadInputHook(commands: ReadonlyArray) { + const handlers = await loadHooks(commands); + const hook = handlers.get("input"); + assert.isDefined(hook); + return hook as InputHook; +} + +describe("Pi skill references", () => { + it("loads every selected skill once while preserving inline prose, whitespace, and images", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-")); + try { + const alpha = NodePath.join(directory, "alpha.md"); + const beta = NodePath.join(directory, "beta.md"); + await NodeFSP.writeFile(alpha, "ALPHA_INSTRUCTIONS"); + await NodeFSP.writeFile(beta, "BETA_INSTRUCTIONS"); + const hook = await loadInputHook([ + { name: "skill:alpha", source: "skill", sourceInfo: { path: alpha } }, + { name: "skill:beta", source: "skill", sourceInfo: { path: beta } }, + ]); + const text = + "Please use the $alpha philosophy, then $beta and $alpha\n```ts\n const x = 1;\n```"; + const images = [{ type: "image", data: "fixture" }]; + const result = await hook({ text, images }, { ui: { notify: assert.fail } }); + assert.equal(result?.action, "transform"); + assert.isTrue(result?.text.startsWith(text + "\n\n")); + assert.equal(result?.text.split("ALPHA_INSTRUCTIONS").length, 2); + assert.equal(result?.text.split("BETA_INSTRUCTIONS").length, 2); + assert.include(result?.text ?? "", `References are relative to ${directory}.`); + assert.strictEqual(result?.images, images); + } finally { + await NodeFSP.rm(directory, { recursive: true }); + } + }); + + it("leaves unknown references and native skill commands for Pi", async () => { + const hook = await loadInputHook([ + { name: "skill:alpha", source: "skill", sourceInfo: { path: "/unused" } }, + ]); + // Punctuation-adjacent references are plain text in the composer, not selected chips. + for (const text of [ + "Explain $HOME", + "/skill:alpha use $alpha", + "Hello", + "$missing", + "Use $alpha, then continue", + "Use ($alpha)", + ]) { + assert.isUndefined(await hook({ text }, { ui: { notify: assert.fail } })); + } + }); + + it("loads additional chips without duplicating a leading native skill", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-")); + try { + const path = NodePath.join(directory, "beta.md"); + await NodeFSP.writeFile(path, "BETA_INSTRUCTIONS"); + const hook = await loadInputHook([ + { name: "skill:alpha", source: "skill", sourceInfo: { path: "/unused" } }, + { name: "skill:beta", source: "skill", sourceInfo: { path } }, + ]); + const text = "/skill:alpha use $alpha and $beta"; + const result = await hook({ text }, { ui: { notify: assert.fail } }); + assert.isTrue(result?.text.startsWith(text + "\n\n")); + assert.notInclude(result?.text ?? "", ' { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pi-skill-mentions-")); + try { + const path = NodePath.join(directory, "readable.md"); + await NodeFSP.writeFile(path, "READABLE_INSTRUCTIONS"); + const hook = await loadInputHook([ + { + name: "skill:missing", + source: "skill", + sourceInfo: { path: NodePath.join(directory, "missing.md") }, + }, + { name: "skill:readable", source: "skill", sourceInfo: { path } }, + ]); + const notices: string[] = []; + const text = "Use $missing and $readable"; + const result = await hook( + { text }, + { + ui: { + notify: (message) => { + notices.push(message); + }, + }, + }, + ); + assert.isTrue(result?.text.startsWith(text + "\n\n")); + assert.include(result?.text ?? "", "READABLE_INSTRUCTIONS"); + assert.equal(notices.length, 1); + assert.include(notices[0] ?? "", "Could not load skill missing"); + } finally { + await NodeFSP.rm(directory, { recursive: true }); + } + }); +}); diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index e11e953f8433..8279a515ed65 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -23,7 +23,9 @@ export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const; export const PI_T3_MCP_EXTENSION_SOURCE = `\ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { stripFrontmatter, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; import { Type } from "typebox"; const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; @@ -210,6 +212,38 @@ function createMcpClient(endpoint: string, token: string) { } export default async function t3McpExtension(pi: ExtensionAPI) { + // Keep skill chips in the request. Pi expands only one leading /skill: + // command, so use its own frontmatter loader for every selected chip here. + pi.on("input", async (event, ctx) => { + if (!event.text.includes("$")) return; + const commands = pi.getCommands().filter((command) => command.source === "skill"); + const nativeSkill = /^\\/skill:([^ ]+)(?: |$)/.exec(event.text)?.[1]; + const seen = new Set(nativeSkill === undefined ? [] : [nativeSkill]); + const blocks: string[] = []; + for (const match of event.text.matchAll(/(^|\\s)\\$([^\\s]+)(?=\\s|$)/g)) { + const name = match[2]; + if (name === undefined || seen.has(name)) continue; + const command = commands.find((candidate) => candidate.name === "skill:" + name); + if (command === undefined) continue; + seen.add(name); + const path = command.sourceInfo.path; + try { + const body = stripFrontmatter(await NodeFSP.readFile(path, "utf8")).trim(); + blocks.push( + \`\\nReferences are relative to \${NodePath.dirname(path)}.\\n\\n\${body}\\n\`, + ); + } catch { + ctx.ui.notify(\`Could not load skill \${name} from \${path}.\`, "error"); + } + } + if (blocks.length === 0) return; + return { + action: "transform", + text: event.text + "\\n\\n" + blocks.join("\\n\\n"), + images: event.images, + }; + }); + // Workaround for an upstream Pi context-budgeting bug: pi-ai reuses the // previous response's usage even when a fork's instructions/tools differ, // then reserves almost all remaining context for output. OpenRouter can From c908cea52510ed29199c3cc477204da425601301 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 22:52:42 -0700 Subject: [PATCH 056/377] fix(server): keep the Claude MCP token out of process arguments (#17408) --- .../Adapters/ClaudeAdapterV2.test.ts | 19 +++++++++++++--- .../Adapters/ClaudeAdapterV2.ts | 22 ++++++++++++++++--- 2 files changed, 35 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 ebabc11d69ee..bfef7f9e9a10 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -478,11 +478,14 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { type: "http", url: "http://127.0.0.1:43123/mcp", headers: { - Authorization: "Bearer secret-claude-token", + Authorization: "${T3_CODE_MCP_AUTHORIZATION}", }, timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, } as const; + const T3_MCP_ENVIRONMENT = { + T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token", + } as const; const withMcpSession = (threadId: ThreadId, run: () => void) => { McpProviderSession.setMcpProviderSession({ @@ -531,6 +534,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { assert.deepEqual(overrides, { allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); }); @@ -547,6 +551,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { assert.deepEqual(overrides, { allowedTools: ["Read", "mcp__t3-code__*"], mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); }); @@ -566,6 +571,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, ], mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); }); @@ -697,11 +703,12 @@ describe("ClaudeAdapterV2 native protocol logging", () => { type: "http", url: "http://127.0.0.1:43123/mcp", headers: { - Authorization: "Bearer secret-claude-token", + Authorization: "${T3_CODE_MCP_AUTHORIZATION}", }, timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, }, + mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" }, }); const options = ClaudeAdapterV2.makeClaudeQueryOptions({ @@ -712,8 +719,14 @@ describe("ClaudeAdapterV2 native protocol logging", () => { nativeThreadId: "native-thread-claude-mcp", resume: false, cwd: "/workspace", - ...overrides, + allowedTools: overrides.allowedTools ?? [], + mcpServers: overrides.mcpServers ?? {}, + environment: { ...overrides.mcpEnvironment }, }); + // mcpServers becomes a CLI argument, readable by every local user; the + // credential may only travel in the child's environment. + assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token"); + assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token"); assert.isObject(options.systemPrompt); const systemPrompt = options.systemPrompt as { readonly type: string; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4cd45b86df7f..de747802d349 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -966,6 +966,13 @@ export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; // not pre-approved), but read-only sandboxes pre-approve only the annotated // read-only orchestrator tools so a read-only session cannot silently spawn // threads or scheduled tasks. +// +// The SDK passes `mcpServers` to the CLI as an inline `--mcp-config` argument, +// and process arguments are readable by every local user. The credential +// therefore travels in the child's environment, which only its owner can read, +// and the CLI expands the `${VAR}` reference when it connects. +const CLAUDE_T3_MCP_AUTHORIZATION_ENV = "T3_CODE_MCP_AUTHORIZATION"; + export function claudeMcpQueryOverrides(input: { readonly threadId: ThreadId; readonly readOnlySandbox: boolean; @@ -973,6 +980,7 @@ export function claudeMcpQueryOverrides(input: { }): { readonly allowedTools?: ReadonlyArray; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + readonly mcpEnvironment?: Readonly>; } { const session = McpProviderSession.readMcpProviderSession(input.threadId); if (session === undefined) { @@ -988,11 +996,12 @@ export function claudeMcpQueryOverrides(input: { type: "http", url: session.endpoint, headers: { - Authorization: session.authorizationHeader, + Authorization: `\${${CLAUDE_T3_MCP_AUTHORIZATION_ENV}}`, }, timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, }, + mcpEnvironment: { [CLAUDE_T3_MCP_AUTHORIZATION_ENV]: session.authorizationHeader }, }; } @@ -1623,6 +1632,7 @@ export function claudeEffectiveQueryPolicyKey( mcpOverrides: { readonly allowedTools?: ReadonlyArray; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + readonly mcpEnvironment?: Readonly>; }, ): string { return JSON.stringify({ @@ -1633,6 +1643,7 @@ export function claudeEffectiveQueryPolicyKey( : { allowedTools: mcpOverrides.allowedTools }), }), mcpServers: mcpOverrides.mcpServers, + mcpEnvironment: mcpOverrides.mcpEnvironment, }); } @@ -7338,9 +7349,14 @@ export function makeClaudeAdapterV2( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - environment: adapterOptions.environment, + environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, + ...(mcpOverrides.allowedTools === undefined + ? {} + : { allowedTools: mcpOverrides.allowedTools }), + ...(mcpOverrides.mcpServers === undefined + ? {} + : { mcpServers: mcpOverrides.mcpServers }), permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined ? {} From 7a2b1c72beed4f560aebb41aa7b26e0792eeb938 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:01:48 +0200 Subject: [PATCH 057/377] fix(server): reconcile Pi native session rewinds (#13839) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ProviderEventIngestor.test.ts | 253 ++++++++++++++ .../orchestration-v2/ProviderEventIngestor.ts | 156 ++++++++- .../src/server/ProviderAdapter.ts | 7 + .../provider-pi/src/server/adapter.test.ts | 311 ++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 192 ++++++++--- 5 files changed, 861 insertions(+), 58 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 1ccbdaf95970..d1b978e3862f 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1,5 +1,8 @@ import { assert, it } from "@effect/vitest"; import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, MessageId, type ModelSelection, NodeId, @@ -14,6 +17,8 @@ import { ProviderDriverKind, ProviderInstanceId, PlanId, + type ProviderThreadId, + ProviderTurnId, RunAttemptId, RunId, RuntimeRequestId, @@ -318,6 +323,254 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect("rolls back runs a provider rewound off its active branch", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + const threadId = threadEvent.threadId; + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const rewoundThreadId = idAllocator.derive.providerThread({ + driver: CODEX_DRIVER, + nativeThreadId: "rewound-thread", + }); + const otherThreadId = idAllocator.derive.providerThread({ + driver: CODEX_DRIVER, + nativeThreadId: "other-thread", + }); + const scopeId = CheckpointScopeId.make("checkpoint-scope:rewound"); + // A run as execution leaves it: attempt, provider turn, root node, and a + // checkpoint once completed. A null native turn id is a weak ref. + const seedRun = Effect.fnUntraced(function* (input: { + readonly ordinal: number; + readonly providerThreadId: ProviderThreadId; + readonly status: "completed" | "interrupted" | "running"; + readonly nativeTurnId: string | null; + }) { + const runId = RunId.make(`run:rewound:${input.ordinal}`); + const attemptId = RunAttemptId.make(`attempt:rewound:${input.ordinal}`); + const nodeId = NodeId.make(`node:rewound:${input.ordinal}`); + const providerTurnId = ProviderTurnId.make(`provider-turn:rewound:${input.ordinal}`); + const completedAt = input.status === "running" ? null : now; + const eventId = () => idAllocator.allocate.event({ threadId }); + const events: Array = [ + { + id: yield* eventId(), + type: "run.created", + threadId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: input.providerThreadId, + userMessageId: MessageId.make(`message:rewound:${input.ordinal}`), + rootNodeId: nodeId, + activeAttemptId: attemptId, + status: input.status, + requestedAt: now, + startedAt: now, + completedAt, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: yield* eventId(), + type: "run-attempt.created", + threadId, + occurredAt: now, + payload: { + id: attemptId, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId: modelSelection.instanceId, + providerThreadId: input.providerThreadId, + providerTurnId, + reason: "initial", + status: input.status, + startedAt: now, + completedAt, + }, + }, + { + id: yield* eventId(), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: input.providerThreadId, + nodeId, + runAttemptId: attemptId, + nativeTurnRef: + input.nativeTurnId === null + ? { + driver: CODEX_DRIVER, + nativeId: `synthetic:${input.ordinal}`, + strength: "weak", + } + : { driver: CODEX_DRIVER, nativeId: input.nativeTurnId, strength: "strong" }, + ordinal: input.ordinal, + status: input.status, + startedAt: now, + completedAt, + }, + }, + { + id: yield* eventId(), + type: "node.updated", + threadId, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId, + parentNodeId: null, + rootNodeId: nodeId, + kind: "root_turn", + status: input.status, + countsForRun: true, + providerThreadId: input.providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: scopeId, + startedAt: now, + completedAt, + }, + }, + ...(input.status === "completed" + ? [ + { + id: yield* eventId(), + type: "checkpoint.captured" as const, + threadId, + occurredAt: now, + payload: { + id: CheckpointId.make(`checkpoint:rewound:${input.ordinal}`), + threadId, + scopeId, + runId, + nodeId, + parentCheckpointId: null, + ordinalWithinScope: input.ordinal, + appRunOrdinal: input.ordinal, + ref: CheckpointRef.make(`refs/t3/checkpoints/rewound/${input.ordinal}`), + status: "ready" as const, + files: [], + capturedAt: now, + }, + }, + ] + : []), + ]; + yield* eventSink.write({ events }); + return runId; + }); + + yield* eventSink.write({ events: [threadEvent] }); + const otherProviderRun = yield* seedRun({ + ordinal: 1, + providerThreadId: otherThreadId, + status: "completed", + nativeTurnId: "other-turn", + }); + const keptRun = yield* seedRun({ + ordinal: 2, + providerThreadId: rewoundThreadId, + status: "completed", + nativeTurnId: "kept-turn", + }); + const abandonedRun = yield* seedRun({ + ordinal: 3, + providerThreadId: rewoundThreadId, + status: "completed", + nativeTurnId: "abandoned-turn", + }); + const unlocatedRun = yield* seedRun({ + ordinal: 4, + providerThreadId: rewoundThreadId, + status: "interrupted", + nativeTurnId: null, + }); + const rewindingRun = yield* seedRun({ + ordinal: 5, + providerThreadId: rewoundThreadId, + status: "running", + nativeTurnId: null, + }); + + yield* ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + runId: rewindingRun, + event: { + type: "provider_thread.updated", + driver: CODEX_DRIVER, + providerThread: { + id: rewoundThreadId, + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver: CODEX_DRIVER, + nativeId: "rewound-thread", + strength: "strong", + }, + nativeConversationHeadRef: { + driver: CODEX_DRIVER, + nativeId: "kept-turn-reply", + strength: "strong", + }, + status: "idle", + firstRunOrdinal: 2, + lastRunOrdinal: 5, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + retainedNativeTurnIds: ["turn-started-outside-t3", "kept-turn"], + }, + }); + + const projection = yield* projectionStore.getThreadProjection(threadId); + const runIds = [otherProviderRun, keptRun, abandonedRun, unlocatedRun, rewindingRun]; + assert.deepEqual( + runIds.map((runId) => projection.runs.find((run) => run.id === runId)?.status), + ["completed", "completed", "rolled_back", "rolled_back", "running"], + ); + assert.deepEqual( + runIds.map((runId) => projection.nodes.find((node) => node.runId === runId)?.status), + ["completed", "completed", "rolled_back", "rolled_back", "running"], + ); + assert.deepEqual( + [otherProviderRun, keptRun, abandonedRun].map( + (runId) => + projection.checkpoints.find((checkpoint) => checkpoint.runId === runId)?.status, + ), + ["ready", "ready", "stale"], + ); + assert.equal( + projection.providerThreads.find((thread) => thread.id === rewoundThreadId)?.lastRunOrdinal, + 5, + ); + }), + ); + it.effect("carries plan-step durations through consecutive and restarted ingestion", () => Effect.gen(function* () { yield* TestClock.setTime(Date.parse("2026-09-07T00:00:00.000Z")); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index b794883c092d..355a73e2199c 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -7,6 +7,7 @@ import { type OrchestrationV2Run, type OrchestrationV2ProviderTurn, type OrchestrationV2Subagent, + type OrchestrationV2ThreadProjection, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -197,6 +198,57 @@ function withPlanStepDurations( }; } +/** Settled runs that carried conversation to the provider. */ +const CONVERSATION_RUN_STATUSES = new Set([ + "completed", + "interrupted", + "failed", +]); + +/** + * Settled runs on a provider thread that a native rewind left off the active + * branch. A run whose provider turn has a strong native ref is judged by that + * ref. A run without one cannot be located, so it falls with everything after + * the last run still on the branch. + */ +function runsOffNativeBranch(input: { + readonly projection: Pick; + readonly providerThreadId: ProviderThreadId; + readonly retainedNativeTurnIds: ReadonlySet; + readonly reportingRunId: RunId | undefined; +}): ReadonlyArray { + const { attempts, providerTurns, runs } = input.projection; + const nativeTurnId = (run: OrchestrationV2Run) => { + const attempt = attempts.find((candidate) => candidate.id === run.activeAttemptId); + if (attempt === undefined) return null; + const ref = providerTurns.find( + (turn) => turn.id === attempt.providerTurnId || turn.runAttemptId === attempt.id, + )?.nativeTurnRef; + return ref?.strength === "strong" ? ref.nativeId : null; + }; + const settled = runs.flatMap((run) => + run.providerThreadId === input.providerThreadId && + run.id !== input.reportingRunId && + CONVERSATION_RUN_STATUSES.has(run.status) + ? [{ run, nativeTurnId: nativeTurnId(run) }] + : [], + ); + const lastRetainedOrdinal = settled.reduce( + (last, { run, nativeTurnId }) => + nativeTurnId !== null && input.retainedNativeTurnIds.has(nativeTurnId) + ? Math.max(last, run.ordinal) + : last, + 0, + ); + return settled + .filter(({ run, nativeTurnId }) => + nativeTurnId === null + ? run.ordinal > lastRetainedOrdinal + : !input.retainedNativeTurnIds.has(nativeTurnId), + ) + .map(({ run }) => run); +} + export interface ProviderEventIngestInput { readonly providerSessionId: ProviderSessionId; readonly providerInstanceId: ProviderInstanceId; @@ -387,6 +439,84 @@ export const layer: Layer.Layer< }, ); + /** + * The provider rewound its conversation outside T3. Retire the runs that + * fell off its active branch with the same run, root-node, and checkpoint + * updates CheckpointRollbackService writes for a T3 revert. The run + * reporting the rewind performed it and stays, so it keeps lastRunOrdinal. + */ + const rollBackRewoundRuns = Effect.fn("ProviderEventIngestor.rollBackRewoundRuns")(function* ( + input: ProviderEventIngestInput, + rewind: { + readonly threadId: ThreadId; + readonly providerThreadId: ProviderThreadId; + readonly retainedNativeTurnIds: ReadonlySet; + }, + ) { + const projection = yield* projections.getThreadRecords(rewind.threadId, [ + "runs", + "attempts", + "providerTurns", + "nodes", + "checkpoints", + ]); + const runs = runsOffNativeBranch({ + projection, + providerThreadId: rewind.providerThreadId, + retainedNativeTurnIds: rewind.retainedNativeTurnIds, + reportingRunId: input.runId, + }); + if (runs.length === 0) return []; + const now = yield* DateTime.now; + const eventId = () => + idAllocator.allocate.event({ + threadId: rewind.threadId, + providerSessionId: input.providerSessionId, + }); + const runIds = new Set(runs.map((run) => run.id)); + const events: Array = []; + for (const checkpoint of projection.checkpoints) { + if (checkpoint.status !== "ready" || checkpoint.runId === null) continue; + if (!runIds.has(checkpoint.runId)) continue; + events.push({ + id: yield* eventId(), + type: "checkpoint.captured", + threadId: rewind.threadId, + runId: checkpoint.runId, + nodeId: checkpoint.nodeId, + providerInstanceId: input.providerInstanceId, + occurredAt: now, + payload: { ...checkpoint, status: "stale" }, + }); + } + for (const run of runs) { + const rootNode = projection.nodes.find((node) => node.id === run.rootNodeId); + events.push({ + id: yield* eventId(), + type: "run.updated", + threadId: rewind.threadId, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "rolled_back", completedAt: now }, + }); + if (rootNode !== undefined) { + events.push({ + id: yield* eventId(), + type: "node.updated", + threadId: rewind.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "rolled_back", completedAt: now }, + }); + } + } + return events; + }); + const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { switch (input.event.type) { @@ -405,14 +535,24 @@ export const layer: Layer.Layer< payload: input.event.providerSession, }), ]; - case "provider_thread.updated": - return [ - yield* makeDomainEvent(input, { - type: "provider-thread.updated", - threadId: input.event.providerThread.appThreadId ?? input.threadId, - payload: input.event.providerThread, - }), - ]; + case "provider_thread.updated": { + const threadId = input.event.providerThread.appThreadId ?? input.threadId; + const updated = yield* makeDomainEvent(input, { + type: "provider-thread.updated", + threadId, + payload: input.event.providerThread, + }); + return input.event.retainedNativeTurnIds === undefined + ? [updated] + : [ + updated, + ...(yield* rollBackRewoundRuns(input, { + threadId, + providerThreadId: input.event.providerThread.id, + retainedNativeTurnIds: new Set(input.event.retainedNativeTurnIds), + })), + ]; + } case "provider_turn.updated": return [ ...(["completed", "interrupted", "failed", "cancelled"].includes( diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 3cc27acd6d5e..c6e6ef486275 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -90,6 +90,13 @@ export const ProviderAdapterV2Event = Schema.Union([ type: Schema.Literal("provider_thread.updated"), driver: ProviderDriverKind, providerThread: OrchestrationV2ProviderThread, + /** + * Set when the provider rewound its own conversation outside T3, for + * example a Pi extension moving the session-tree leaf back. Lists every + * native turn id still on the active branch; the ingestor rolls back + * earlier runs whose turns fell off it. + */ + retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), Schema.Struct({ type: Schema.Literal("provider_turn.updated"), diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 562fb5ca0790..f737c3d5878d 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -2019,6 +2019,317 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect.each([false, true])("follows an extension rewind with summarize=%s", (summarize) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const root = { type: "model_change", id: "root", parentId: null }; + const user = (id: string, parentId: string) => ({ + type: "message", + id, + parentId, + message: { role: "user", content: [{ type: "text", text: id }] }, + }); + const reply = (id: string, parentId: string) => ({ + type: "message", + id, + parentId, + message: { role: "assistant", content: [] }, + }); + fake.queueEntries({ entries: [root], leafId: "root" }); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + // Runs one turn to its terminal. `listings` answer the turn's + // get_entries requests in order. + const settle = Effect.fnUntraced(function* ( + text: string, + runOrdinal: number, + ...listings: ReadonlyArray + ) { + yield* startTurn(runtime, providerThread, "default", [], text, undefined, runOrdinal); + yield* fake.takeRequest("prompt"); + for (const listing of listings) fake.queueEntries(listing); + // Pi acks every prompt; a command-only prompt then settles from + // an idle probe instead of agent events. + yield* fake.emit({ type: "response", command: "prompt", success: true }); + if (!text.startsWith("/")) { + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + } + const turn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + const thread = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.status === "idle", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + return { + turnRef: + turn.type === "provider_turn.updated" ? turn.providerTurn.nativeTurnRef : undefined, + head: + thread.type === "provider_thread.updated" + ? thread.providerThread.nativeConversationHeadRef?.nativeId + : undefined, + retained: + thread.type === "provider_thread.updated" ? thread.retainedNativeTurnIds : undefined, + }; + }); + + yield* settle("prompt A", 1, { + entries: [user("uA", "root"), reply("aA", "uA")], + leafId: "aA", + }); + const promptB = yield* settle("prompt B", 2, { + entries: [user("uB", "aA"), reply("aB", "uB")], + leafId: "aB", + }); + assert.equal(promptB.turnRef?.nativeId, "uB"); + assert.isUndefined(promptB.retained); + + // The extension moves the leaf back to A's reply. With a summary, + // pi appends a branch_summary child of that reply as the new leaf. + const rewoundLeaf = summarize ? "summary" : "aA"; + const summary = summarize ? [{ type: "branch_summary", id: "summary", parentId: "aA" }] : []; + const rewind = yield* settle( + "/rewind", + 3, + { entries: summary, leafId: rewoundLeaf }, + { + entries: [ + root, + user("uA", "root"), + reply("aA", "uA"), + user("uB", "aA"), + reply("aB", "uB"), + ...summary, + ], + leafId: rewoundLeaf, + }, + ); + assert.deepEqual(rewind.retained, ["uA"]); + assert.equal(rewind.head, rewoundLeaf); + if (summarize) { + // The summary is this turn's only entry, and fork cannot re-root + // before a non-user entry, so rollback may not pass it. + assert.equal(rewind.turnRef?.strength, "weak"); + } else { + // Nothing of this turn is on the branch; rollback can pass it. + assert.isNull(rewind.turnRef); + } + + // get_entries is append-ordered: after a rewind to an older leaf, + // the abandoned branch still comes first in the next window. + const promptC = yield* settle("prompt C", 4, { + entries: [ + ...(summarize ? [] : [user("uB", "aA"), reply("aB", "uB")]), + user("uC", rewoundLeaf), + reply("aC", "uC"), + ], + leafId: "aC", + }); + assert.deepEqual(promptC.turnRef, { + driver: PI_PROVIDER, + nativeId: "uC", + strength: "strong", + }); + assert.isUndefined(promptC.retained); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + // Corrupted session data can loop its parentId chain. Finalizing the turn + // must still finish, and must not report a branch to roll runs back to. + it.effect.each(["turn window", "full tree"] as const)( + "finishes a turn whose %s has a parentId cycle", + (cycleIn) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const root = { type: "model_change", id: "root", parentId: null }; + const cycle = [ + { type: "message", id: "x", parentId: "y", message: { role: "user", content: [] } }, + { type: "message", id: "y", parentId: "x", message: { role: "assistant", content: [] } }, + ]; + fake.queueEntries({ entries: [root], leafId: "root" }); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread, "default", [], "prompt", undefined, 1); + yield* fake.takeRequest("prompt"); + if (cycleIn === "turn window") { + fake.queueEntries({ entries: cycle, leafId: "x" }); + } else { + // The leaf is outside the `since` window, so this reads as a rewind + // and the full tree is walked for the surviving branch. + fake.queueEntries({ entries: [], leafId: "x" }); + fake.queueEntries({ entries: [root, ...cycle], leafId: "x" }); + } + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + const thread = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.status === "idle", + ); + assert.isUndefined( + thread.type === "provider_thread.updated" ? thread.retainedNativeTurnIds : null, + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each(["missing parent", "invalid parent", "duplicate id"] as const)( + "does not reconcile malformed session entries: %s", + (malformed) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const root = { type: "model_change", id: "root", parentId: null }; + fake.queueEntries({ entries: [root], leafId: "root" }); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + const entry = { + type: "message", + id: "x", + message: { role: "user", content: [] }, + ...(malformed === "missing parent" + ? {} + : { parentId: malformed === "invalid parent" ? 42 : null }), + }; + fake.queueEntries({ entries: [], leafId: "x" }); + fake.queueEntries({ + entries: [ + root, + entry, + ...(malformed === "duplicate id" ? [{ ...entry, parentId: "root" }] : []), + ], + leafId: "x", + }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + const thread = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.status === "idle", + ); + assert.isUndefined( + thread.type === "provider_thread.updated" ? thread.retainedNativeTurnIds : null, + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("does not reconcile a replacement session tree as a native rewind", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.queueEntries({ + entries: [{ type: "model_change", id: "old-leaf", parentId: null }], + leafId: "old-leaf", + }); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const finish = Effect.fnUntraced(function* () { + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const update = yield* takeEvent( + (event) => + event.type === "provider_thread.updated" && event.providerThread.status === "idle", + ); + yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isUndefined( + update.type === "provider_thread.updated" ? update.retainedNativeTurnIds : null, + ); + }); + // A failed cursor read is followed by a full listing from a new session. + fake.queueEntries({ entries: [] }); + yield* finish(); + fake.queueEntries({ + entries: [ + { + type: "message", + id: "new-user", + parentId: null, + message: { role: "user", content: [] }, + }, + ], + leafId: "new-user", + }); + yield* finish(); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("rolls back past turns that left nothing in the session tree", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const turn = (ordinal: number, entryId: string | null): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`turn-${ordinal}`), + providerThreadId: providerThread.id, + nodeId: NodeId.make(`node-${ordinal}`), + runAttemptId: null, + nativeTurnRef: + entryId === null ? null : { driver: PI_PROVIDER, nativeId: entryId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + const rollBackToFirst = (turns: ReadonlyArray) => + runtime.rollbackThread({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-1"), + appRunOrdinal: 1, + providerTurn: turns[0]!, + }, + providerThreadTurns: turns, + }); + const forkedEntries = () => + fake + .allRequests() + .filter((request) => request.type === "fork") + .map((request) => request.entryId); + + // Only a turn that left nothing follows the target: already there. + yield* rollBackToFirst([turn(1, "u1"), turn(2, null)]); + assert.deepEqual(forkedEntries(), []); + // Pi re-roots before the first later turn that left entries. + yield* rollBackToFirst([turn(1, "u1"), turn(2, null), turn(3, "u3")]); + assert.deepEqual(forkedEntries(), ["u3"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index d8dedd71d0b6..e0d93d51a010 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -341,8 +341,11 @@ interface ActivePiTurn { } interface PiTurnTreeRefs { - readonly turnStartEntryId: string | null; + /** Omitted keeps the synthetic ref; null means the turn left nothing on the active branch. */ + readonly nativeTurnRef: OrchestrationV2ProviderRef | null | undefined; readonly leafId: string | null; + /** Set after a native rewind: every user entry still on the active branch. */ + readonly retainedNativeTurnIds: ReadonlyArray | undefined; } interface PendingPiPrompt { @@ -567,6 +570,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const updateProviderThread = ( state: PiThreadState, patch: Partial, + retainedNativeTurnIds?: ReadonlyArray, ) => Effect.gen(function* () { const updatedAt = yield* DateTime.now; @@ -575,6 +579,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( type: "provider_thread.updated", driver: PI_PROVIDER, providerThread: state.providerThread, + ...(retainedNativeTurnIds === undefined ? {} : { retainedNativeTurnIds }), }); }); @@ -1380,53 +1385,78 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // ── turn lifecycle ──────────────────────────────────── + /** One `get_entries` listing, or null when the request failed or named no leaf. */ + const listSessionEntries = (since: string | null, timeoutMs: number) => + request({ type: "get_entries", ...(since === null ? {} : { since }) }, timeoutMs).pipe( + Effect.map((data) => { + const leafId = recordField(data, "leafId"); + const entries = piSessionEntries(recordField(data, "entries")); + return entries !== null && (typeof leafId === "string" || leafId === null) + ? { entries, leafId } + : null; + }), + Effect.orElseSucceed(() => null), + ); + /** * Locate this turn's first user entry and the new leaf in pi's session * tree. The user-entry id becomes the provider turn's native ref (the * point `fork` rolls back to); the leaf becomes the conversation head. + * `get_entries` is append-ordered and keeps abandoned branches, so both + * come from walking the leaf's `parentId` chain, never from list order. + * A leaf that no longer descends from the previous one means something + * outside T3 rewound the session, such as an extension calling + * `navigateTree`. The user entries left on the active branch are then + * returned so orchestration can roll back runs that fell off it. * Pure bookkeeping: failures degrade to the synthetic refs. */ const captureTurnTreeRefs = Effect.fnUntraced(function* ( timeoutMs = PI_REQUEST_TIMEOUT_MS, ): Effect.fn.Return { const cursorWasStale = leafCursorStale; - const cursor = cursorWasStale ? null : lastKnownLeaf; - const data = yield* request( - { - type: "get_entries", - ...(cursor === null ? {} : { since: cursor }), - }, - timeoutMs, - ).pipe(Effect.orElseSucceed(() => undefined)); - if (data === undefined) { - // Pi may have advanced past `lastKnownLeaf` while this failed, so the - // cursor can no longer be trusted to bound a single turn. + const previousLeaf = lastKnownLeaf; + const fullListing = cursorWasStale || previousLeaf === null; + const listing = yield* listSessionEntries(fullListing ? null : previousLeaf, timeoutMs); + const turnWalk = + listing === null ? null : walkPiBranch(listing.entries, listing.leafId, previousLeaf); + const rewound = turnWalk !== null && previousLeaf !== null && !turnWalk.reachedStop; + // A `since` window ends at the previous leaf; the rewound branch + // continues past it, so its ancestry needs the whole tree. + const tree = rewound && !fullListing ? yield* listSessionEntries(null, timeoutMs) : listing; + if (listing === null || turnWalk === null || tree === null) { + // The read failed or its ancestry loops. Pi may have advanced past + // `lastKnownLeaf` meanwhile, so the cursor can no longer be trusted + // to bound a single turn. leafCursorStale = true; return null; } - const entries = recordField(data, "entries"); - const leafId = recordString(data, "leafId"); - if (leafId !== undefined) lastKnownLeaf = leafId; - // Without a trustworthy cursor this window spans more than one turn, so - // its first user entry belongs to an earlier turn. Re-sync the cursor - // and skip the turn-start ref rather than pointing rollback too far - // back; the next turn gets an accurate ref again. + lastKnownLeaf = listing.leafId; leafCursorStale = false; - const firstUserEntryId = cursorWasStale - ? undefined - : Array.isArray(entries) - ? entries - .filter( - (entry) => - recordField(entry, "type") === "message" && - recordString(recordField(entry, "message"), "role") === "user", - ) - .map((entry) => recordString(entry, "id")) - .find((id) => id !== undefined) + const turnStartEntryId = turnWalk.userEntryIds.at(-1); + // Only a branch walked to its root lists every survivor; a partial or + // cyclic walk would roll back runs that are still in the conversation. + // A replaced session has a different tree. Only an old leaf still in + // this tree proves an in-session rewind rather than a session switch. + const branch = + rewound && previousLeaf !== null && tree.entries.has(previousLeaf) + ? walkPiBranch(tree.entries, listing.leafId, null) : undefined; return { - turnStartEntryId: firstUserEntryId ?? null, - leafId: leafId ?? null, + // Without a trustworthy cursor the walk spans more than one turn, so + // its earliest user entry belongs to an earlier turn. Keep the + // synthetic ref rather than pointing rollback too far back; the next + // turn gets an accurate ref again. A turn that left nothing on the + // active branch (a command, or a rewind) gets no ref: a rollback + // past it needs no fork of its own. + nativeTurnRef: cursorWasStale + ? undefined + : turnStartEntryId !== undefined + ? providerRef(turnStartEntryId) + : turnWalk.entryCount === 0 + ? null + : undefined, + leafId: listing.leafId, + retainedNativeTurnIds: branch?.reachedStop === true ? branch.userEntryIds : undefined, }; }); @@ -1467,20 +1497,27 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( threadId: turn.turnInput.threadId, providerTurn: { ...turn.providerTurn, - ...(treeRefs?.turnStartEntryId == null + ...(treeRefs === null || treeRefs.nativeTurnRef === undefined ? {} - : { nativeTurnRef: providerRef(treeRefs.turnStartEntryId) }), + : { nativeTurnRef: treeRefs.nativeTurnRef }), status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", completedAt, ...(tokenUsage === undefined ? {} : { tokenUsage }), }, }); - yield* updateProviderThread(state, { - status: "idle", - ...(treeRefs?.leafId == null - ? {} - : { nativeConversationHeadRef: providerRef(treeRefs.leafId) }), - }); + yield* updateProviderThread( + state, + { + status: "idle", + ...(treeRefs === null + ? {} + : { + nativeConversationHeadRef: + treeRefs.leafId === null ? null : providerRef(treeRefs.leafId), + }), + }, + treeRefs?.retainedNativeTurnIds, + ); yield* updateProviderSession( failure !== null ? "error" : "ready", failure?.message ?? null, @@ -3053,11 +3090,68 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); +interface PiSessionEntry { + readonly parentId: string | null; + readonly isUserMessage: boolean; +} + +/** Index a `get_entries` listing, rejecting ambiguous ancestry. */ +function piSessionEntries(entries: unknown): ReadonlyMap | null { + const byId = new Map(); + if (!Array.isArray(entries)) return null; + for (const entry of entries) { + const id = recordString(entry, "id"); + const parentId = recordField(entry, "parentId"); + if (id === undefined || byId.has(id) || (parentId !== null && typeof parentId !== "string")) + return null; + byId.set(id, { + parentId, + isUserMessage: + recordField(entry, "type") === "message" && + recordString(recordField(entry, "message"), "role") === "user", + }); + } + return byId; +} + +/** + * Walk pi's active branch from `leafId` towards the root through `entries`, + * stopping at `stopAt`. Returns how many entries it passed, the user entries + * among them (newest first), and whether the walk reached `stopAt`. Leaving a + * partial listing anywhere else means the branch does not descend from `stopAt`. + * Returns null when the `parentId` chain loops, which only corrupted session + * data can produce; callers treat that like a failed listing. + */ +function walkPiBranch( + entries: ReadonlyMap, + leafId: string | null, + stopAt: string | null, +): { + readonly entryCount: number; + readonly userEntryIds: ReadonlyArray; + readonly reachedStop: boolean; +} | null { + const visited = new Set(); + const userEntryIds: Array = []; + let current = leafId; + while (current !== null && current !== stopAt) { + const entry = entries.get(current); + if (entry === undefined) break; + if (visited.has(current)) return null; + visited.add(current); + if (entry.isUserMessage) userEntryIds.push(current); + current = entry.parentId; + } + return { entryCount: visited.size, userEntryIds, reachedStop: current === stopAt }; +} + /** * Resolve the pi session-tree entry `fork` should re-root at for a rollback. - * Returns `null` when no turns follow the target (nothing to discard) and - * `undefined` when the boundary turn has no captured entry ref (only - * turn-boundary refs recorded by `captureTurnTreeRefs` are strong). + * Turns without a ref left nothing on the active branch, so the boundary is + * the first discarded turn that has one. Returns `null` when no such turn + * follows the target (nothing to discard) and `undefined` when the boundary + * turn has no captured entry ref (only turn-boundary refs recorded by + * `captureTurnTreeRefs` are strong). */ function piRollbackForkEntry(input: { readonly target: @@ -3067,13 +3161,11 @@ function piRollbackForkEntry(input: { }): string | null | undefined { const boundaryOrdinal = input.target.type === "thread_start" ? 0 : input.target.providerTurn.ordinal; - const discarded = input.providerThreadTurns - .filter((turn) => turn.ordinal > boundaryOrdinal) - .sort((a, b) => a.ordinal - b.ordinal); - const boundary = discarded[0]; - if (boundary === undefined) return null; - const ref = boundary.nativeTurnRef; - if (ref === null || ref.strength !== "strong" || ref.nativeId === null) return undefined; + const ref = input.providerThreadTurns + .filter((turn) => turn.ordinal > boundaryOrdinal && turn.nativeTurnRef !== null) + .sort((a, b) => a.ordinal - b.ordinal)[0]?.nativeTurnRef; + if (ref == null) return null; + if (ref.strength !== "strong" || ref.nativeId === null) return undefined; return ref.nativeId; } From cb46c62504aca99295a4a8879644625a20685b7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 23:07:15 -0700 Subject: [PATCH 058/377] test(provider-pi): cover continuation offers through the driver (#17407) --- .../provider-pi/src/server/adapter.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index f737c3d5878d..ed3236d763c7 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -37,7 +37,13 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; -import { makePiAdapterV2, PI_PROVIDER, type PiAdapterV2Options } from "./adapter.ts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + makePiAdapterV2, + PiAdapterV2Driver, + PI_PROVIDER, + type PiAdapterV2Options, +} from "./adapter.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; const layerTest = Layer.mergeAll( @@ -317,11 +323,28 @@ const makeAdapter = Effect.fnUntraced(function* ( ? forkFake.spawner.spawn(command) : fake.spawner.spawn(command), ); + // Continuation cases go through the driver, which wires the offer from the + // environment the way production does. + if (continuationRequests !== undefined) { + return yield* PiAdapterV2Driver.create({ + instanceId: PI_INSTANCE_ID, + displayName: undefined, + enabled: true, + environment: [], + config: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, { + ...continuationRequests, + take: Effect.never, + }), + ); + } return yield* makePiAdapterV2({ instanceId: PI_INSTANCE_ID, settings: { enabled: true, binaryPath: "pi", launchArgs, customModels: [] }, environment: {}, - ...(continuationRequests === undefined ? {} : { continuationRequests }), }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); }); From 43f8a8de17a7ac1baa7a3cf36d681856de2d8add Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 23:08:35 -0700 Subject: [PATCH 059/377] refactor(provider-acp-registry): move the ACP Registry into its own package (#17405) --- apps/mobile/package.json | 1 + apps/mobile/src/lib/providerClients.ts | 2 + apps/server/package.json | 2 +- .../Adapters/AcpAdapterV2.test.ts | 4 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 8 +- .../Adapters/AcpRegistryAdapterV2.testkit.ts | 4 +- .../ProviderAdapterRegistry.test.ts | 2 +- .../builtInProviderAdapterDrivers.ts | 2 +- .../server/src/provider/AcpRegistryCatalog.ts | 22 ------ .../{acp => }/AcpRegistryProbe.test.ts | 9 ++- apps/server/src/provider/CodexChatGptAuth.ts | 4 +- apps/server/src/provider/ProviderHostLive.ts | 1 + .../ProviderInstanceRegistryHydration.ts | 5 +- apps/server/src/provider/builtInDrivers.ts | 5 +- apps/server/src/server.ts | 5 +- apps/server/src/terminal/Manager.test.ts | 2 +- apps/server/src/terminal/Manager.ts | 4 +- .../src/terminal/NodePtyAdapter.test.ts | 2 +- apps/server/src/terminal/NodePtyAdapter.ts | 2 +- apps/server/src/ws.ts | 4 +- apps/web/package.json | 1 + .../components/settings/providerDriverMeta.ts | 9 +-- package.json | 2 +- packages/contracts/src/settings.ts | 67 ---------------- packages/provider-acp-registry/package.json | 51 +++++++++++++ packages/provider-acp-registry/src/client.ts | 28 +++++++ packages/provider-acp-registry/src/server.ts | 16 ++++ .../AcpRegistryRuntimeCoordinator.test.ts | 50 +++--------- .../server}/AcpRegistryRuntimeCoordinator.ts | 13 +--- .../src/server}/AcpRegistrySupport.test.ts | 66 +++++++++------- .../src/server}/AcpRegistrySupport.ts | 44 ++++++++--- .../src/server/adapter.ts | 17 ++--- .../src/server/auth.test.ts | 11 +-- .../provider-acp-registry/src/server/auth.ts | 8 +- .../src/server/authenticationState.test.ts | 5 +- .../src/server/authenticationState.ts | 7 +- .../src/server/devinAcp.ts | 0 .../src/server/driver.test.ts | 11 +-- .../src/server/driver.ts | 23 +++--- .../provider-acp-registry/src/server/probe.ts | 4 +- .../testing/AcpRegistrySupport.npm.fixture.sh | 0 .../testing/AcpRegistrySupport.uv.fixture.sh | 0 .../provider-acp-registry/src/settings.ts | 76 +++++++++++++++++++ packages/provider-acp-registry/src/testing.ts | 29 +++++++ packages/provider-acp-registry/tsconfig.json | 8 ++ .../src/server/AcpSessionRuntime.ts | 12 +-- packages/provider-core/package.json | 4 + .../src/server}/ProviderAuthFlow.test.ts | 0 .../src/server}/ProviderAuthFlow.ts | 8 +- .../provider-core/src/server/ProviderHost.ts | 4 + .../src/server/managedProvider.test.ts | 2 + .../src/server/OpenCodeRuntime.ts | 12 +-- .../src/server/v2/OpenCode2Server.ts | 2 +- packages/provider-testing/src/host.ts | 30 ++++++-- packages/shared/package.json | 4 + .../shared/src}/PtyAdapter.ts | 2 +- pnpm-lock.yaml | 52 ++++++++++++- scripts/release-smoke.ts | 1 + 58 files changed, 470 insertions(+), 299 deletions(-) delete mode 100644 apps/server/src/provider/AcpRegistryCatalog.ts rename apps/server/src/provider/{acp => }/AcpRegistryProbe.test.ts (98%) create mode 100644 packages/provider-acp-registry/package.json create mode 100644 packages/provider-acp-registry/src/client.ts create mode 100644 packages/provider-acp-registry/src/server.ts rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/AcpRegistryRuntimeCoordinator.test.ts (91%) rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/AcpRegistryRuntimeCoordinator.ts (98%) rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/AcpRegistrySupport.test.ts (97%) rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/AcpRegistrySupport.ts (98%) rename apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts => packages/provider-acp-registry/src/server/adapter.ts (96%) rename apps/server/src/provider/acp/AcpRegistryAuth.test.ts => packages/provider-acp-registry/src/server/auth.test.ts (98%) rename apps/server/src/provider/acp/AcpRegistryAuth.ts => packages/provider-acp-registry/src/server/auth.ts (98%) rename apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts => packages/provider-acp-registry/src/server/authenticationState.test.ts (95%) rename apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts => packages/provider-acp-registry/src/server/authenticationState.ts (95%) rename apps/server/src/orchestration-v2/Adapters/DevinAcp.ts => packages/provider-acp-registry/src/server/devinAcp.ts (100%) rename apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts => packages/provider-acp-registry/src/server/driver.test.ts (98%) rename apps/server/src/provider/Drivers/AcpRegistryDriver.ts => packages/provider-acp-registry/src/server/driver.ts (97%) rename apps/server/src/provider/acp/AcpRegistryProbe.ts => packages/provider-acp-registry/src/server/probe.ts (99%) rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/testing/AcpRegistrySupport.npm.fixture.sh (100%) rename {apps/server/src/provider/acp => packages/provider-acp-registry/src/server}/testing/AcpRegistrySupport.uv.fixture.sh (100%) create mode 100644 packages/provider-acp-registry/src/settings.ts create mode 100644 packages/provider-acp-registry/src/testing.ts create mode 100644 packages/provider-acp-registry/tsconfig.json rename {apps/server/src/provider => packages/provider-core/src/server}/ProviderAuthFlow.test.ts (100%) rename {apps/server/src/provider => packages/provider-core/src/server}/ProviderAuthFlow.ts (98%) rename {apps/server/src/terminal => packages/shared/src}/PtyAdapter.ts (98%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 97a0cd33ac74..0efb5e01a11d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -69,6 +69,7 @@ "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", + "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index d434e6bde328..96755bd85410 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,4 +1,5 @@ import { isProviderDriverKind } from "@t3tools/contracts"; +import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; @@ -13,6 +14,7 @@ const providerClients = makeProviderClientRegistry([ museClient, openCodeClient, piClient, + acpRegistryClient, ]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ diff --git a/apps/server/package.json b/apps/server/package.json index cb0f2dfcdc13..bb166039abd9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,9 +36,9 @@ "@ff-labs/fff-node": "0.9.4", "@muse-code/sdk": "1.4.2", "@napi-rs/keyring": "^1.3.0", - "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index a8c958209214..bda7b91e735a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2,7 +2,7 @@ import { normalizeDevinSessionUpdate, normalizeDevinToolCall, extractDevinSubagentUpdate, -} from "./DevinAcp.ts"; +} from "@t3tools/provider-acp-registry/testing"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -95,7 +95,7 @@ import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing"; import { acpRegistryPromptFailure, registerMistralVibeAcpExtensions, -} from "./AcpRegistryAdapterV2.ts"; +} from "@t3tools/provider-acp-registry/testing"; const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 3762cbb6769a..728e8bc4c91d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -19,8 +19,8 @@ import * as ServerSettings from "../../serverSettings.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, -} from "../../provider/acp/AcpRegistryProbe.ts"; -import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; +} from "@t3tools/provider-acp-registry/testing"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -31,12 +31,12 @@ import { } from "./AcpAdapterV2.testkit.ts"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server"; import { ACP_REGISTRY_PROVIDER, - AcpRegistryAdapterV2Driver, makeAcpRegistryAdapterV2, acpRegistryPromptFailure, -} from "./AcpRegistryAdapterV2.ts"; +} from "@t3tools/provider-acp-registry/testing"; const registryUrl = "https://registry.test/registry.json"; const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index aefcd190f33d..c30152529006 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AcpRegistrySettings } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -24,7 +24,7 @@ import { ACP_REGISTRY_DEFAULT_INSTANCE_ID, ACP_REGISTRY_PROVIDER, makeAcpRegistryAdapterV2, -} from "./AcpRegistryAdapterV2.ts"; +} from "@t3tools/provider-acp-registry/testing"; const REPLAY_SETTINGS = Schema.decodeUnknownSync(AcpRegistrySettings)({ agentId: "replay-agent", diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts index 3a11ab7c2722..3e8f6dfa297b 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts @@ -18,7 +18,7 @@ import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index f1ce18c12f88..77cd6f3933d5 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -3,7 +3,7 @@ import type { ProviderDriverKind } from "@t3tools/contracts"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv, -} from "./Adapters/AcpRegistryAdapterV2.ts"; +} from "@t3tools/provider-acp-registry/server"; import { ClaudeAdapterV2Driver, type ClaudeAdapterV2DriverEnv, diff --git a/apps/server/src/provider/AcpRegistryCatalog.ts b/apps/server/src/provider/AcpRegistryCatalog.ts deleted file mode 100644 index 9141793ee545..000000000000 --- a/apps/server/src/provider/AcpRegistryCatalog.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import * as ServerConfig from "../config.ts"; -import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; -import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts"; - -/** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ -export const layer = Layer.merge( - Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - return AcpRegistrySupport.AcpRegistryCatalog.layer({ - cacheDir: config.providerStatusCacheDir, - toolsDir: path.join(config.baseDir, "tools"), - }); - }), - ), - AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, -); diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts similarity index 98% rename from apps/server/src/provider/acp/AcpRegistryProbe.test.ts rename to apps/server/src/provider/AcpRegistryProbe.test.ts index 37066157fc0b..7b69ca58baaa 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -3,7 +3,8 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -21,12 +22,12 @@ import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, probeAcpRegistryConfiguration, -} from "./AcpRegistryProbe.ts"; -import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +} from "@t3tools/provider-acp-registry/testing"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; const instanceId = ProviderInstanceId.make("acpRegistry_codex"); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts"); const decodeSettings = Schema.decodeSync(AcpRegistrySettings); describe("ACP Registry probe", () => { diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index b5f30c2e54fd..b6845a029721 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -19,8 +19,8 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; -import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; +import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/ProviderAuthFlow"; import { HttpClient, HttpClientRequest } from "effect/http"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 19042fc2805e..3a924b39bd66 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -35,6 +35,7 @@ export const layer = Layer.effect( }, settings: { get: serverSettings.getSettings, + withSnapshot: serverSettings.withSettingsSnapshot, changes: serverSettings.streamChanges, subscribe: serverSettings.subscribeChanges, }, diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 177aace04c57..6048f48636ea 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -38,8 +38,7 @@ import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; -import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; -import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as ProviderHostLive from "./ProviderHostLive.ts"; import type { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -144,7 +143,7 @@ export const layer: Layer.Layer< configMap: initialConfigMap, }).pipe( Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), - Layer.provide(AcpRegistryCatalog.layer), + Layer.provide(AcpRegistrySupport.layerFromHost), Layer.provide(ProviderHostLive.layer), ); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 1e21b8dc7cc1..845ffcf137c4 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,7 +20,10 @@ * * @module provider/builtInDrivers */ -import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegistryDriver.ts"; +import { + AcpRegistryDriver, + type AcpRegistryDriverEnv, +} from "@t3tools/provider-acp-registry/server"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f91976f38ece..6a698c2e46ab 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -45,7 +45,8 @@ import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; -import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; +import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -597,7 +598,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(layerPtyAdapter), // Search, prepare, status inspection, and turn launch share one registry // cache so every client and provider instance sees the same prepared agents. - Layer.provideMerge(AcpRegistryCatalog.layer.pipe(Layer.provide(layerServerSettings))), + Layer.provideMerge(AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer))), // Shared native/canonical NDJSON writers used by both the per-instance // V2 drivers and the orchestration runtime. Provide resource attribution so // the rewritten telemetry pipeline can account for logical NDJSON writes. diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 95819d285236..5a40f7bbb933 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -41,7 +41,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; -import * as PtyAdapter from "./PtyAdapter.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 23b9e549e0de..562307477dcf 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -45,7 +45,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; -import { acpRegistryManagedBinaryDirectories } from "../provider/acp/AcpRegistrySupport.ts"; +import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; @@ -77,7 +77,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; -import * as PtyAdapter from "./PtyAdapter.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; export { TerminalCwdError, diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index fbfa62ab993a..e8c2e4d75ff7 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -14,7 +14,7 @@ import * as Scheduler from "effect/Scheduler"; import { expect, vi } from "vite-plus/test"; import * as NodePtyAdapter from "./NodePtyAdapter.ts"; -import * as PtyAdapter from "./PtyAdapter.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; function makeNativeProcess(pid = 42) { const events = new NodeEvents.EventEmitter(); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 8ba78ee4d288..ed4aee84006e 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -9,7 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as PtyAdapter from "./PtyAdapter.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; export class NodePtyModuleLoadError extends Schema.TaggedError()( "NodePtyModuleLoadError", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 31b07cea2baf..1b045f7e963f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -165,8 +165,8 @@ import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore. import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; -import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; -import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; +import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; +import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; diff --git a/apps/web/package.json b/apps/web/package.json index 5e9c5e1e9785..1d2d93ed36a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp-registry": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-grok": "workspace:*", diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index e18a271f9278..b64f5d714bad 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,10 +1,10 @@ import { - AcpRegistrySettings, AntigravitySettings, ClaudeSettings, CodexSettings, ProviderDriverKind, } from "@t3tools/contracts"; +import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; import { cursorClient } from "@t3tools/provider-cursor/client"; import { grokClient } from "@t3tools/provider-grok/client"; @@ -34,10 +34,5 @@ export const providerClients = makeProviderClientRegistry([ }, museClient, piClient, - { - driverKind: ProviderDriverKind.make("acpRegistry"), - label: "ACP Registry", - settingsSchema: AcpRegistrySettings, - hasDefaultInstance: false, - }, + acpRegistryClient, ]); diff --git a/package.json b/package.json index df724bd09a28..55869fc4b391 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index bd8af77a4ea1..0e8027b9f5fd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -788,73 +788,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; -export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); -export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; - -export const AcpRegistrySettings = makeProviderSettingsSchema( - { - source: Schema.Literals(["registry", "local"]).pipe( - Schema.withDecodingDefault(Effect.succeed("registry")), - Schema.annotateKey({ - title: "ACP source", - providerSettingsForm: { - control: "select", - options: [ - { value: "registry", label: "ACP Registry" }, - { value: "local", label: "Local command" }, - ], - }, - }), - ), - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(true)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - agentId: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Registry agent ID", - description: "Agent identifier from the official ACP Registry, for example 'devin'.", - providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, - }), - ), - commandPath: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Executable override", - description: - "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", - providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, - }), - ), - commandArgs: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - authMethodId: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Authentication method", - description: - "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", - providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, - }), - ), - distribution: AcpRegistryDistributionPreference.pipe( - Schema.withDecodingDefault(Effect.succeed("auto")), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - customModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["source", "agentId", "commandPath", "authMethodId"], - }, -); -export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; - /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the diff --git a/packages/provider-acp-registry/package.json b/packages/provider-acp-registry/package.json new file mode 100644 index 000000000000..a9c474a9148f --- /dev/null +++ b/packages/provider-acp-registry/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/provider-acp-registry", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, + "./server/AcpRegistrySupport": { + "types": "./src/server/AcpRegistrySupport.ts", + "import": "./src/server/AcpRegistrySupport.ts" + }, + "./server/AcpRegistryRuntimeCoordinator": { + "types": "./src/server/AcpRegistryRuntimeCoordinator.ts", + "import": "./src/server/AcpRegistryRuntimeCoordinator.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@noble/hashes": "catalog:", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-acp": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "effect-acp": "workspace:*", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-acp-registry/src/client.ts b/packages/provider-acp-registry/src/client.ts new file mode 100644 index 000000000000..9d5cbd2fdce0 --- /dev/null +++ b/packages/provider-acp-registry/src/client.ts @@ -0,0 +1,28 @@ +/** + * ACP Registry's client definition: settings form, label, and the registry + * glyph clients fall back to while an agent's own icon loads. + * Browser- and React Native-safe. + * + * @module provider-acp-registry/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { AcpRegistrySettings } from "./settings.ts"; + +export const acpRegistryClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("acpRegistry"), + label: "ACP Registry", + settingsSchema: AcpRegistrySettings, + // Each instance is an agent the user adds from the registry. + hasDefaultInstance: false, + icon: { + viewBox: "0 0 576 220", + fill: { light: "#000000", dark: "#FFFFFF" }, + paths: [ + { + d: "M568.003 115.821L517.278 27.9661C507.183 10.4816 489.084 0.0227367 468.894 0.0227367C448.727 0.0227367 430.674 10.4361 420.556 27.8752L343.251 161.749H242.755C236.23 161.749 230.386 158.384 227.135 152.745C223.861 147.106 223.861 140.376 227.135 134.715L277.861 46.8603C281.112 41.2216 286.955 37.8338 293.481 37.8338C300.006 37.8338 305.827 41.1988 309.101 46.8603L312.125 52.0897C313.353 54.2042 315.604 55.5002 318.036 55.5002C320.469 55.5002 322.743 54.1815 323.948 52.067L337.385 28.5118C338.795 26.0335 338.522 22.9413 336.703 20.7586C325.699 7.57131 309.874 0 293.322 0C292.662 0 292.003 0 291.321 0.0454733C272.04 0.75031 254.76 11.1864 245.074 27.9434L200.215 105.657L155.81 29.1484C145.465 11.2092 126.594 0.0227367 106.608 0.0227367C105.949 0.0227367 105.289 0.0227367 104.607 0.06821C85.3265 0.773047 68.0467 11.2092 58.3608 27.9661L7.65806 115.821C-6.25678 139.899 -0.868187 168.82 21.05 187.759C29.8945 195.422 41.6039 199.628 54.0181 199.628H148.648C151.081 199.628 153.332 198.332 154.56 196.217L168.52 172.026C169.748 169.911 169.748 167.319 168.52 165.205C167.292 163.09 165.041 161.794 162.608 161.794H56.0417C49.5163 161.794 43.6729 158.429 40.4216 152.79C37.1475 147.152 37.1475 140.422 40.4216 134.76L91.1471 46.9057C94.3985 41.2671 100.242 37.8793 106.767 37.8793C113.293 37.8793 119.113 41.2443 122.387 46.9057L194.826 172.526C195.031 172.89 195.258 173.208 195.531 173.526C198.76 178.665 202.83 183.485 207.786 187.782C216.631 195.444 228.34 199.651 240.754 199.651H321.424L315.581 209.769C314.353 211.883 314.353 214.475 315.581 216.59C316.809 218.704 319.059 220 321.492 220H349.436C351.868 220 354.119 218.704 355.347 216.59L364.396 200.901L367.17 196.468C367.17 196.468 367.261 196.331 367.284 196.263L453.274 46.9285C456.525 41.2898 462.369 37.902 468.894 37.902C475.42 37.902 481.263 41.2671 484.514 46.9285L535.24 134.783C538.491 140.422 538.514 147.174 535.24 152.813C531.988 158.452 526.145 161.84 519.62 161.84H418.669C416.236 161.84 413.985 163.136 412.757 165.25L398.774 189.442C397.546 191.556 397.546 194.148 398.774 196.263C400.002 198.377 402.253 199.673 404.686 199.673H518.21C539.81 199.673 559.295 188.237 569.026 169.843C578.053 152.79 577.666 132.623 567.981 115.843L568.003 115.821Z", + }, + ], + }, +}); diff --git a/packages/provider-acp-registry/src/server.ts b/packages/provider-acp-registry/src/server.ts new file mode 100644 index 000000000000..b8ca0bbe1872 --- /dev/null +++ b/packages/provider-acp-registry/src/server.ts @@ -0,0 +1,16 @@ +/** + * ACP Registry's server entry: the driver and adapter driver the server + * registers, the catalog layer it provides once, and the managed-binary + * directories the terminal manager keeps off agent PATHs. + * + * The catalog and runtime coordinator are services; import them from + * `./server/AcpRegistrySupport` and `./server/AcpRegistryRuntimeCoordinator`. + * + * @module provider-acp-registry/server + */ +export { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./server/driver.ts"; +export { + AcpRegistryAdapterV2Driver, + type AcpRegistryAdapterV2DriverEnv, +} from "./server/adapter.ts"; +export { acpRegistryManagedBinaryDirectories } from "./server/AcpRegistrySupport.ts"; diff --git a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.test.ts similarity index 91% rename from apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts rename to packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.test.ts index 619d794db833..6fd3d627f05f 100644 --- a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.test.ts @@ -6,10 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; -import type { - AcpRegistryAvailableCommands, - AcpRegistryLiveConfiguration, -} from "./AcpRegistryProbe.ts"; +import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; describe("AcpRegistryRuntimeCoordinator", () => { @@ -33,10 +30,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { yield* Deferred.succeed(releaseForeground, undefined); yield* Fiber.join(foreground); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("interrupts an active background probe when foreground startup begins", () => @@ -64,10 +58,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { yield* Deferred.succeed(releaseForeground, undefined); yield* Fiber.join(foreground); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("serializes native session mutations across callers", () => @@ -94,10 +85,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { yield* Fiber.join(first); yield* Fiber.join(second); expect(Option.isSome(yield* Deferred.poll(secondEntered))).toBe(true); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("replays and replaces late command advertisements per provider instance", () => @@ -145,10 +133,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { ); yield* coordinator.clearAvailableCommands(codex); expect(Option.isNone(yield* coordinator.getAvailableCommands(codex))).toBe(true); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("replays and replaces live configuration per provider instance", () => @@ -189,10 +174,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement)); yield* coordinator.clearLiveConfiguration(instanceId); expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("requires matching user consent before accepting URL authentication", () => @@ -234,10 +216,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { expect(yield* Fiber.join(request)).toBe(true); expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); yield* Fiber.interrupt(consumer); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("expires stale URL authentication actions without accepting them", () => @@ -271,10 +250,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { ).toBe(false); expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); yield* Fiber.interrupt(consumer); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("publishes a replacement URL action before retiring the previous request", () => @@ -326,10 +302,7 @@ describe("AcpRegistryRuntimeCoordinator", () => { ).toBe(true); expect(yield* Fiber.join(replacement)).toBe(true); yield* Fiber.interrupt(consumer); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); it.effect("clears a published URL action when its request is interrupted", () => @@ -358,9 +331,6 @@ describe("AcpRegistryRuntimeCoordinator", () => { yield* Deferred.await(cleared); expect(Option.isNone(yield* coordinator.getUrlAuthAction(instanceId))).toBe(true); yield* Fiber.interrupt(consumer); - }).pipe( - Effect.provide(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer), - Effect.scoped, - ), + }).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped), ); }); diff --git a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts similarity index 98% rename from apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts rename to packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts index d14f2586b5ba..ef86c87fa113 100644 --- a/apps/server/src/provider/acp/AcpRegistryRuntimeCoordinator.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts @@ -15,10 +15,7 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import type { - AcpRegistryAvailableCommands, - AcpRegistryLiveConfiguration, -} from "./AcpRegistryProbe.ts"; +import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts"; interface AvailableCommandsUpdate { readonly instanceId: ProviderInstanceId; @@ -99,11 +96,7 @@ export class AcpRegistryRuntimeCoordinator extends Context.Service< onUpdate: (action: AcpRegistryUrlAuthAction | null) => Effect.Effect, ) => Effect.Effect; } ->()("t3/provider/acp/AcpRegistryRuntimeCoordinator") { - static get layer() { - return layer; - } -} +>()("@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator") {} const make = Effect.gen(function* () { const foregroundStarts = yield* PubSub.unbounded(); @@ -366,4 +359,4 @@ const make = Effect.gen(function* () { }); }); -const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make); +export const layer = Layer.effect(AcpRegistryRuntimeCoordinator, make); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts similarity index 97% rename from apps/server/src/provider/acp/AcpRegistrySupport.test.ts rename to packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 959fab6e256e..35cf0f199b9d 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -1,6 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import { HostProcessArchitecture, HostProcessEnvironment, @@ -19,7 +24,7 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; -import * as ServerSettings from "../../serverSettings.ts"; +import { layerTestProviderHost, TestProviderHostSettings } from "@t3tools/provider-testing/host"; const registryUrl = "https://registry.test/registry.json"; const archiveUrl = "https://registry.test/example-agent.bin"; @@ -55,7 +60,7 @@ function layerResolver( ) { return Layer.mergeAll( NodeServices.layer, - ServerSettings.layerTest(), + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), Layer.succeed(HostProcessPlatform, "linux"), Layer.succeed(HostProcessArchitecture, "x64"), Layer.succeed(HostProcessEnvironment, environment), @@ -768,7 +773,12 @@ describe("AcpRegistrySupport", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), + ), + ), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessArchitecture, "x64"), ); @@ -1364,26 +1374,27 @@ describe("AcpRegistrySupport", () => { toolsDir: `${cacheDir}/tools`, registryUrl, }); - const serverSettings = yield* ServerSettings.ServerSettingsService; + const hostSettings = yield* TestProviderHostSettings; const instanceId = ProviderInstanceId.make("uninstall-reference"); - yield* serverSettings.updateProviderInstance({ - operation: "upsert", - instanceId, - instance: { - driver: ProviderDriverKind.make("acpRegistry"), - displayName: "Uninstall reference", - enabled: true, - config: { - agentId, - commandPath: "/local/agent", - ...(source ? { source } : {}), + yield* hostSettings.set({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Uninstall reference", + enabled: true, + config: { + agentId, + commandPath: "/local/agent", + ...(source ? { source } : {}), + }, }, }, }); const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); - yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + yield* hostSettings.set(DEFAULT_SERVER_SETTINGS); const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); expect(second).toEqual({ agentId: "example-agent", removed: referenced }); @@ -1481,23 +1492,24 @@ describe("AcpRegistrySupport", () => { const agentRoot = `${cacheDir}/tools/${agent.id}`; expect(yield* fileSystem.exists(agentRoot)).toBe(true); - const serverSettings = yield* ServerSettings.ServerSettingsService; + const hostSettings = yield* TestProviderHostSettings; const instanceId = ProviderInstanceId.make("registry-reference"); - yield* serverSettings.updateProviderInstance({ - operation: "upsert", - instanceId, - instance: { - driver: ProviderDriverKind.make("acpRegistry"), - displayName: "Registry reference", - enabled: true, - config: { agentId: agent.id }, + yield* hostSettings.set({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "Registry reference", + enabled: true, + config: { agentId: agent.id }, + }, }, }); expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ agentId: agent.id, removed: false, }); - yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); + yield* hostSettings.set(DEFAULT_SERVER_SETTINGS); expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ agentId: agent.id, removed: true, diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts similarity index 98% rename from apps/server/src/provider/acp/AcpRegistrySupport.ts rename to packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index dd4ef66f7876..9055f9e070eb 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -9,9 +9,9 @@ import { type AcpRegistrySearchInput, type AcpRegistrySearchResult, type AcpRegistryDistribution as AcpRegistryDistributionKind, - type AcpRegistryDistributionPreference, - type AcpRegistrySettings, } from "@t3tools/contracts"; +import type { AcpRegistryDistributionPreference } from "../settings.ts"; +import type { AcpRegistrySettings } from "../settings.ts"; import { HostProcessArchitecture, HostProcessEnvironment, @@ -32,6 +32,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -42,7 +43,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import * as ServerSettings from "../../serverSettings.ts"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; @@ -503,11 +504,30 @@ export class AcpRegistryCatalog extends Context.Service< input: AcpRegistryManagedBinaryUninstallInput, ) => Effect.Effect; } ->()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { - static layer(options: AcpRegistryCatalogOptions) { - return Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); - } -} +>()("@t3tools/provider-acp-registry/server/AcpRegistrySupport/AcpRegistryCatalog") {} + +const layer = (options: AcpRegistryCatalogOptions) => + Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); + +/** + * The server-lifetime catalog, with the runtime coordinator built alongside + * it so setup, snapshots, and turn launch share one of each. Its cache lives + * in the host's provider status cache and installed agents under the T3 + * home's `tools` directory. + */ +export const layerFromHost = Layer.merge( + Layer.unwrap( + Effect.gen(function* () { + const { paths } = yield* ProviderHost.ProviderHost; + const path = yield* Path.Path; + return layer({ + cacheDir: paths.providerStatusCacheDir, + toolsDir: path.join(paths.baseDir, "tools"), + }); + }), + ), + AcpRegistryRuntimeCoordinator.layer, +); export interface AcpRegistryCatalogOptions { readonly cacheDir: string; @@ -603,7 +623,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path - | ServerSettings.ServerSettingsService + | ProviderHost.ProviderHost > { const crypto = yield* Crypto.Crypto; const sha256Hex = (data: Uint8Array) => @@ -612,7 +632,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverSettings = yield* ServerSettings.ServerSettingsService; + const { settings: hostSettings } = yield* ProviderHost.ProviderHost; const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const hostEnvironment = yield* HostProcessEnvironment; @@ -1842,8 +1862,8 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct }); const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => - serverSettings - .withSettingsSnapshot((settings) => + hostSettings + .withSnapshot((settings) => installSemaphore.withPermits(1)( Effect.gen(function* () { const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/packages/provider-acp-registry/src/server/adapter.ts similarity index 96% rename from apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts rename to packages/provider-acp-registry/src/server/adapter.ts index 99168e136592..223aef7b81d2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -2,12 +2,9 @@ import { normalizeDevinSessionUpdate, normalizeDevinToolCall, extractDevinSubagentUpdate, -} from "./DevinAcp.ts"; -import { - AcpRegistrySettings, - defaultInstanceIdForDriver, - ProviderDriverKind, -} from "@t3tools/contracts"; +} from "./devinAcp.ts"; +import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; @@ -26,12 +23,12 @@ import { normalizeAcpRegistryCommands, normalizeAcpRegistryLiveConfiguration, normalizeAcpRegistryWebUrl, -} from "../../provider/acp/AcpRegistryProbe.ts"; -import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; -import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; +} from "./probe.ts"; +import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.test.ts b/packages/provider-acp-registry/src/server/auth.test.ts similarity index 98% rename from apps/server/src/provider/acp/AcpRegistryAuth.test.ts rename to packages/provider-acp-registry/src/server/auth.test.ts index f19be06f2634..5a9fc049c493 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.test.ts +++ b/packages/provider-acp-registry/src/server/auth.test.ts @@ -1,10 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { - AcpRegistrySettings, - ProviderInstanceId, - type ProviderAuthState, -} from "@t3tools/contracts"; +import { ProviderInstanceId, type ProviderAuthState } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -13,8 +10,8 @@ import * as Stream from "effect/Stream"; import type * as AcpSchema from "effect-acp/compat"; import { AcpRequestError } from "effect-acp/errors"; -import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; -import { makeAcpRegistryAuth } from "./AcpRegistryAuth.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import { makeAcpRegistryAuth } from "./auth.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; diff --git a/apps/server/src/provider/acp/AcpRegistryAuth.ts b/packages/provider-acp-registry/src/server/auth.ts similarity index 98% rename from apps/server/src/provider/acp/AcpRegistryAuth.ts rename to packages/provider-acp-registry/src/server/auth.ts index 3232b7b370a2..7189d5a4fdec 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -1,9 +1,9 @@ import { ProviderSetupError, - type AcpRegistrySettings, type ProviderAuthMethod, type ProviderInstanceId, } from "@t3tools/contracts"; +import type { AcpRegistrySettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -15,9 +15,9 @@ import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/process"; import type * as AcpSchema from "effect-acp/compat"; -import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; -import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; -import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./AcpRegistryProbe.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; +import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./probe.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts b/packages/provider-acp-registry/src/server/authenticationState.test.ts similarity index 95% rename from apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts rename to packages/provider-acp-registry/src/server/authenticationState.test.ts index 8f5e1ba63c2c..ee3edcee4923 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.test.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.test.ts @@ -1,11 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { AcpRegistrySettings, ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; -import { makeAcpRegistryAuthenticationState } from "./AcpRegistryAuthenticationState.ts"; +import { makeAcpRegistryAuthenticationState } from "./authenticationState.ts"; const settings = Schema.decodeSync(AcpRegistrySettings)({ agentId: "devin" }); diff --git a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts b/packages/provider-acp-registry/src/server/authenticationState.ts similarity index 95% rename from apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts rename to packages/provider-acp-registry/src/server/authenticationState.ts index 986ed6d0ca72..f01f8f2a8dab 100644 --- a/apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.ts @@ -1,8 +1,5 @@ -import type { - AcpRegistrySettings, - ProviderInstanceEnvironment, - ProviderInstanceId, -} from "@t3tools/contracts"; +import type { ProviderInstanceEnvironment, ProviderInstanceId } from "@t3tools/contracts"; +import type { AcpRegistrySettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; diff --git a/apps/server/src/orchestration-v2/Adapters/DevinAcp.ts b/packages/provider-acp-registry/src/server/devinAcp.ts similarity index 100% rename from apps/server/src/orchestration-v2/Adapters/DevinAcp.ts rename to packages/provider-acp-registry/src/server/devinAcp.ts diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts similarity index 98% rename from apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts rename to packages/provider-acp-registry/src/server/driver.test.ts index ceb9c695a590..3a67cc3d86fd 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -1,14 +1,11 @@ -import { - AcpRegistryOperationError, - AcpRegistrySettings, - ProviderInstanceId, -} from "@t3tools/contracts"; +import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; +import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import { acpRegistrySnapshotReadiness, applyAcpRegistryAvailableCommands, @@ -16,7 +13,7 @@ import { buildCheckedAcpRegistrySnapshot, checkAcpRegistryProviderReadiness, checkAcpRegistryProviderStatus, -} from "./AcpRegistryDriver.ts"; +} from "./driver.ts"; const decodeSettings = Schema.decodeSync(AcpRegistrySettings); const identity = { diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/packages/provider-acp-registry/src/server/driver.ts similarity index 97% rename from apps/server/src/provider/Drivers/AcpRegistryDriver.ts rename to packages/provider-acp-registry/src/server/driver.ts index be570db9160c..17ca9ea3ddcb 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -1,5 +1,4 @@ import { - AcpRegistrySettings, officialAcpRegistryIconUrlForAgentId, ProviderDriverKind, resolveOfficialAcpRegistryIconUrl, @@ -8,6 +7,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Clock from "effect/Clock"; @@ -22,12 +22,9 @@ import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/process"; import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import { - AcpRegistryAdapterV2Driver, - type AcpRegistryAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; -import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; +import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { @@ -54,11 +51,11 @@ import { logoutAcpRegistry, probeAcpRegistryConfiguration, setAcpRegistryProvider, -} from "../acp/AcpRegistryProbe.ts"; -import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; -import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts"; -import * as AcpRegistryAuth from "../acp/AcpRegistryAuth.ts"; -import * as AcpRegistryAuthenticationState from "../acp/AcpRegistryAuthenticationState.ts"; +} from "./probe.ts"; +import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; +import * as AcpRegistryAuth from "./auth.ts"; +import * as AcpRegistryAuthenticationState from "./authenticationState.ts"; const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const decodeSettings = Schema.decodeSync(AcpRegistrySettings); @@ -73,7 +70,7 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ packageName: null, }); -const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => { +const makeUnsupportedTextGeneration = (): ProviderTextGeneration => { const unsupported = (operation: string) => Effect.fail( new TextGenerationError({ diff --git a/apps/server/src/provider/acp/AcpRegistryProbe.ts b/packages/provider-acp-registry/src/server/probe.ts similarity index 99% rename from apps/server/src/provider/acp/AcpRegistryProbe.ts rename to packages/provider-acp-registry/src/server/probe.ts index 9fc142c35dc3..c88dacb8f063 100644 --- a/apps/server/src/provider/acp/AcpRegistryProbe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -3,7 +3,6 @@ import { AcpRegistryOperationError, AcpRegistryListSessionsResult, AcpRegistryProbeResult, - AcpRegistrySettings, type AcpRegistryProbeAuthMethod, type AcpRegistryProbeModel, type AcpRegistryUrlAuthAction, @@ -11,6 +10,7 @@ import { type ServerProviderSkill, type ServerProviderSlashCommand, } from "@t3tools/contracts"; +import { AcpRegistrySettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -23,7 +23,7 @@ import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -import * as PtyAdapter from "../../terminal/PtyAdapter.ts"; +import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import { parseSessionModeState } from "@t3tools/provider-acp/server/runtimeModel"; import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; diff --git a/apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh b/packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.npm.fixture.sh similarity index 100% rename from apps/server/src/provider/acp/testing/AcpRegistrySupport.npm.fixture.sh rename to packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.npm.fixture.sh diff --git a/apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh b/packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.uv.fixture.sh similarity index 100% rename from apps/server/src/provider/acp/testing/AcpRegistrySupport.uv.fixture.sh rename to packages/provider-acp-registry/src/server/testing/AcpRegistrySupport.uv.fixture.sh diff --git a/packages/provider-acp-registry/src/settings.ts b/packages/provider-acp-registry/src/settings.ts new file mode 100644 index 000000000000..bb9274a05981 --- /dev/null +++ b/packages/provider-acp-registry/src/settings.ts @@ -0,0 +1,76 @@ +/** + * ACP Registry instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-acp-registry/settings + */ +import { TrimmedString, makeProviderSettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); +export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; + +export const AcpRegistrySettings = makeProviderSettingsSchema( + { + source: Schema.Literals(["registry", "local"]).pipe( + Schema.withDecodingDefault(Effect.succeed("registry")), + Schema.annotateKey({ + title: "ACP source", + providerSettingsForm: { + control: "select", + options: [ + { value: "registry", label: "ACP Registry" }, + { value: "local", label: "Local command" }, + ], + }, + }), + ), + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + agentId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Registry agent ID", + description: "Agent identifier from the official ACP Registry, for example 'devin'.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "persist" }, + }), + ), + commandPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Executable override", + description: + "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", + providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, + }), + ), + commandArgs: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + authMethodId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Authentication method", + description: + "Optional ACP authentication method ID. By default, the first agent-managed method is selected.", + providerSettingsForm: { placeholder: "auto", clearWhenEmpty: "omit" }, + }), + ), + distribution: AcpRegistryDistributionPreference.pipe( + Schema.withDecodingDefault(Effect.succeed("auto")), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["source", "agentId", "commandPath", "authMethodId"], + }, +); +export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts new file mode 100644 index 000000000000..f1c64a89c15c --- /dev/null +++ b/packages/provider-acp-registry/src/testing.ts @@ -0,0 +1,29 @@ +/** + * ACP Registry internals the server's replay testkit and ACP adapter tests + * drive directly. + * + * @module provider-acp-registry/testing + */ +export { + ACP_REGISTRY_DEFAULT_INSTANCE_ID, + ACP_REGISTRY_PROVIDER, + acpRegistryPromptFailure, + makeAcpRegistryAdapterV2, + registerMistralVibeAcpExtensions, +} from "./server/adapter.ts"; +export { + extractDevinSubagentUpdate, + normalizeDevinSessionUpdate, + normalizeDevinToolCall, +} from "./server/devinAcp.ts"; +export { + acpRegistryProbeFailure, + acpRegistryProbeResult, + listAcpRegistrySessions, + logoutAcpRegistry, + normalizeAcpRegistryAuthMethods, + normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, + type AcpRegistryAvailableCommands, + type AcpRegistryLiveConfiguration, +} from "./server/probe.ts"; diff --git a/packages/provider-acp-registry/tsconfig.json b/packages/provider-acp-registry/tsconfig.json new file mode 100644 index 000000000000..024a9dc2d66f --- /dev/null +++ b/packages/provider-acp-registry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext", "esnext.disposable"] + }, + "include": ["src"] +} diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 8b060291f8b8..ae3423c6f921 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1329,17 +1329,7 @@ export class AcpSessionRuntime extends Context.Service< payload: unknown, ) => Effect.Effect; } ->()("@t3tools/provider-acp/server/AcpSessionRuntime") { - static layer( - options: AcpSessionRuntimeOptions, - ): Layer.Layer< - AcpSessionRuntime, - EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto - > { - return Layer.effect(AcpSessionRuntime, make(options)); - } -} +>()("@t3tools/provider-acp/server/AcpSessionRuntime") {} interface AcpStartedState extends AcpSessionRuntimeStartResult {} diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index a733491676b7..b069cc572989 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -99,6 +99,10 @@ "types": "./src/server/ProviderAdapter.ts", "import": "./src/server/ProviderAdapter.ts" }, + "./server/ProviderAuthFlow": { + "types": "./src/server/ProviderAuthFlow.ts", + "import": "./src/server/ProviderAuthFlow.ts" + }, "./server/ProviderEventLoggers": { "types": "./src/server/ProviderEventLoggers.ts", "import": "./src/server/ProviderEventLoggers.ts" diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/packages/provider-core/src/server/ProviderAuthFlow.test.ts similarity index 100% rename from apps/server/src/provider/ProviderAuthFlow.test.ts rename to packages/provider-core/src/server/ProviderAuthFlow.test.ts diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/packages/provider-core/src/server/ProviderAuthFlow.ts similarity index 98% rename from apps/server/src/provider/ProviderAuthFlow.ts rename to packages/provider-core/src/server/ProviderAuthFlow.ts index ff7c5cfd14b3..b1cdde7637a7 100644 --- a/apps/server/src/provider/ProviderAuthFlow.ts +++ b/packages/provider-core/src/server/ProviderAuthFlow.ts @@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import type * as ProviderAuthService from "./ProviderAuthService.ts"; +import type * as ProviderAuth from "./auth.ts"; export interface ProviderAuthFlowContext { readonly flowId: string; @@ -61,9 +61,7 @@ interface Flow { /** Adapters do login and credential handling; this owns client consent and flow lifetime. */ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { readonly instanceId: ProviderInstanceId; - readonly credentialBinding: NonNullable< - ProviderAuthService.ProviderAuthController["credentialBinding"] - >; + readonly credentialBinding: NonNullable; readonly methods: Effect.Effect, ProviderSetupError>; readonly defaultMethodId?: string; /** Stored account profiles can change the advertised methods after auth/logout. */ @@ -145,7 +143,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { ); yield* refreshMethods.pipe(Effect.forkIn(scope)); - const controller: ProviderAuthService.ProviderAuthController = { + const controller: ProviderAuth.ProviderAuthController = { credentialBinding: options.credentialBinding, adoptCredentials: (update, stopSessions) => Effect.gen(function* () { diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 9e889f1f816a..050ad0882fd9 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -49,6 +49,10 @@ interface ProviderHostService { readonly paths: ProviderHostPaths; readonly settings: { readonly get: Effect.Effect; + /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */ + readonly withSnapshot: ( + use: (settings: ServerSettings) => Effect.Effect, + ) => Effect.Effect; /** Every settings change after subscription, starting with the next one. */ readonly changes: Stream.Stream; /** Changes buffered from the moment the scoped subscription is acquired. */ diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 58eae9fbbd31..dab08b246ce3 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -113,6 +113,7 @@ function layerProviderHost(input: { }, settings: input.settings ?? { get: Effect.succeed(DEFAULT_SERVER_SETTINGS), + withSnapshot: (use) => use(DEFAULT_SERVER_SETTINGS), changes: Stream.empty, subscribe: Effect.succeed(Stream.empty), }, @@ -285,6 +286,7 @@ describe("makeManagedServerProvider", () => { const serverSettingsChanges = yield* PubSub.unbounded(); const hostSettings: ProviderHost.ProviderHost["Service"]["settings"] = { get: Ref.get(serverSettingsRef), + withSnapshot: (use) => Ref.get(serverSettingsRef).pipe(Effect.flatMap(use)), changes: Stream.empty, subscribe: PubSub.subscribe(serverSettingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index 935294575057..91e1a6e11d5e 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -105,10 +105,10 @@ export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERRO readonly operation: string; readonly cause?: unknown; readonly detail: string; -}> { - static readonly is = (u: unknown): u is OpenCodeRuntimeError => - P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); -} +}> {} + +export const isOpenCodeRuntimeError = (u: unknown): u is OpenCodeRuntimeError => + P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); @@ -116,7 +116,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { } export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (OpenCodeRuntimeError.is(cause)) return cause.detail; + if (isOpenCodeRuntimeError(cause)) return cause.detail; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful @@ -583,7 +583,7 @@ function ensureRuntimeError( detail: string, cause: unknown, ): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) + return isOpenCodeRuntimeError(cause) ? cause : new OpenCodeRuntimeError({ operation, detail, cause }); } diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index 80b3dda1a101..ea50c22b37a5 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -123,7 +123,7 @@ export const verifyServer = (client: OpenCodeClient) => ), }), Effect.mapError((cause) => - OpenCodeRuntime.OpenCodeRuntimeError.is(cause) + OpenCodeRuntime.isOpenCodeRuntimeError(cause) ? cause : new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "server.info", diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index 243664857910..3316f1a9df2c 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -7,26 +7,38 @@ */ import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; export interface TestProviderHostOptions { /** Session fallback cwd. Defaults to the test process cwd. */ readonly cwd?: string; + /** Initial settings. A test changes them through `TestProviderHostSettings`. */ readonly settings?: ServerSettings; /** Whether background work such as status probes may run. Defaults to `true`. */ readonly runBackgroundWork?: boolean; } +/** Lets a test change the settings its `layerTestProviderHost` reports. */ +export class TestProviderHostSettings extends Context.Service< + TestProviderHostSettings, + { readonly set: (settings: ServerSettings) => Effect.Effect } +>()("@t3tools/provider-testing/host/TestProviderHostSettings") {} + export const layerTestProviderHost = ( options: TestProviderHostOptions = {}, -): Layer.Layer => - Layer.effect( - ProviderHost.ProviderHost, +): Layer.Layer< + ProviderHost.ProviderHost | TestProviderHostSettings, + never, + FileSystem.FileSystem | Path.Path +> => + Layer.effectContext( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -39,9 +51,9 @@ export const layerTestProviderHost = ( for (const directory of [stateDir, providerStatusCacheDir, attachmentsDir]) { yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); } - const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const credentials = new Map(); - return ProviderHost.ProviderHost.of({ + const host = ProviderHost.ProviderHost.of({ paths: { cwd: options.cwd ?? process.cwd(), baseDir, @@ -50,7 +62,8 @@ export const layerTestProviderHost = ( attachmentsDir, }, settings: { - get: Effect.succeed(settings), + get: Ref.get(settings), + withSnapshot: (use) => Ref.get(settings).pipe(Effect.flatMap(use)), changes: Stream.empty, subscribe: Effect.succeed(Stream.empty), }, @@ -69,5 +82,10 @@ export const layerTestProviderHost = ( }; }), }); + return Context.make(ProviderHost.ProviderHost, host).pipe( + Context.add(TestProviderHostSettings, { + set: (next) => Ref.set(settings, next), + }), + ); }), ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 4942d700cadd..fdc368b6c399 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -470,6 +470,10 @@ "./mcpResponsePayloads": { "types": "./src/mcpResponsePayloads.ts", "import": "./src/mcpResponsePayloads.ts" + }, + "./PtyAdapter": { + "types": "./src/PtyAdapter.ts", + "import": "./src/PtyAdapter.ts" } }, "scripts": { diff --git a/apps/server/src/terminal/PtyAdapter.ts b/packages/shared/src/PtyAdapter.ts similarity index 98% rename from apps/server/src/terminal/PtyAdapter.ts rename to packages/shared/src/PtyAdapter.ts index c56dfa93efe6..7abb51cbfe6d 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/packages/shared/src/PtyAdapter.ts @@ -63,4 +63,4 @@ export class PtyAdapter extends Context.Service< */ readonly spawn: (input: PtySpawnInput) => Effect.Effect; } ->()("t3/terminal/PtyAdapter") {} +>()("@t3tools/shared/PtyAdapter") {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a965df78a824..711ef69a8bbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,9 @@ importers: '@t3tools/mobile-terminal-native': specifier: file:./modules/t3-terminal version: file:apps/mobile/modules/t3-terminal + '@t3tools/provider-acp-registry': + specifier: workspace:* + version: link:../../packages/provider-acp-registry '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -597,15 +600,15 @@ importers: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 - '@noble/hashes': - specifier: 'catalog:' - version: 1.8.0 '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 '@t3tools/provider-acp': specifier: workspace:* version: link:../../packages/provider-acp + '@t3tools/provider-acp-registry': + specifier: workspace:* + version: link:../../packages/provider-acp-registry '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -748,6 +751,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/provider-acp-registry': + specifier: workspace:* + version: link:../../packages/provider-acp-registry '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core @@ -1138,6 +1144,46 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-acp-registry: + dependencies: + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-acp': + specifier: workspace:* + version: link:../provider-acp + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + effect-acp: + specifier: workspace:* + version: link:../effect-acp + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 3eabff5674fe..fd8921b67c12 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -32,6 +32,7 @@ const workspaceFiles = [ "packages/provider-testing/package.json", "packages/provider-pi/package.json", "packages/provider-acp/package.json", + "packages/provider-acp-registry/package.json", "packages/provider-cursor/package.json", "packages/provider-grok/package.json", "packages/provider-muse/package.json", From 101f8b2f55df50d4f8d70ed8e6b8e0edf71a2f31 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 9 Oct 2026 01:59:50 -0700 Subject: [PATCH 060/377] fix(server): relay client updates no longer drop the host off T3 Connect (#17366) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 88 +++++++++++++++++-- .../src/cloud/ManagedEndpointRuntime.ts | 49 ++++++++--- docs/internals/t3-connect.md | 9 +- 3 files changed, 123 insertions(+), 23 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 3a16c58fb60c..19ada4264683 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -851,7 +851,7 @@ describe("CloudManagedEndpointRuntime", () => { ); it.effect( - "runs an older relay client while the pinned one installs, then restarts on it and prunes", + "runs an older relay client while the pinned one installs, then hands off to it and prunes", () => Effect.gen(function* () { const olderClient = { @@ -870,7 +870,7 @@ describe("CloudManagedEndpointRuntime", () => { const releaseInstall = yield* Deferred.make(); const pruned = yield* Deferred.make(); const spawned = yield* Queue.unbounded(); - const killed: Array = []; + const olderStopped = yield* Deferred.make(); const output = yield* Queue.unbounded(); const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { @@ -878,7 +878,11 @@ describe("CloudManagedEndpointRuntime", () => { yield* Queue.offer(spawned, executable); const handle = makeHandle({ pid: executable === pinnedClient.executablePath ? 2 : 1, - onKill: () => killed.push(executable), + onKill: () => { + if (executable === olderClient.executablePath) { + Deferred.doneUnsafe(olderStopped, Effect.void); + } + }, ...(executable === pinnedClient.executablePath ? { output: Stream.fromQueue(output) } : {}), @@ -916,7 +920,8 @@ describe("CloudManagedEndpointRuntime", () => { yield* Deferred.succeed(releaseInstall, undefined); expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); - expect(killed).toEqual([olderClient.executablePath]); + // The older connector keeps serving until the pinned one registers. + expect(Option.isNone(yield* Deferred.poll(olderStopped))).toBe(true); yield* Queue.offer( output, @@ -924,6 +929,7 @@ describe("CloudManagedEndpointRuntime", () => { "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n", ), ); + yield* Deferred.await(olderStopped); yield* Deferred.await(pruned); }), ); @@ -1118,7 +1124,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("requests recovery when the updated relay client fails to start", () => + it.effect("keeps the older connector when the updated relay client fails to start", () => Effect.gen(function* () { const olderClient = { status: "available", @@ -1133,18 +1139,26 @@ describe("CloudManagedEndpointRuntime", () => { version: RelayClient.CLOUDFLARED_VERSION, } as const; let installed = false; + let olderKilled = false; const releaseInstall = yield* Deferred.make(); + const pinnedAttempted = yield* Deferred.make(); const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; if (executable === pinnedClient.executablePath) { + yield* Deferred.succeed(pinnedAttempted, undefined); return yield* PlatformError.systemError({ _tag: "PermissionDenied", module: "ChildProcess", method: "spawn", }); } - const handle = makeHandle({ pid: 6, onKill: () => {} }); + const handle = makeHandle({ + pid: 6, + onKill: () => { + olderKilled = true; + }, + }); yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); return handle; }), @@ -1171,6 +1185,68 @@ describe("CloudManagedEndpointRuntime", () => { const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const; expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 }); + yield* Deferred.succeed(releaseInstall, undefined); + yield* Deferred.await(pinnedAttempted); + // The failed swap leaves the older connector active and running. + expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 }); + expect(olderKilled).toBe(false); + }), + ); + + it.effect("requests recovery when the older connector exited during a failed swap", () => + Effect.gen(function* () { + const olderClient = { + status: "available", + executablePath: "/managed/2025.9.0/cloudflared", + source: "managed", + version: "2025.9.0", + } as const; + const pinnedClient = { + status: "available", + executablePath: "/managed/pinned/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + } as const; + let installed = false; + const releaseInstall = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; + if (executable === pinnedClient.executablePath) { + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }); + } + // The older connector has exited by the time the swap fails. + const handle = makeHandle({ pid: 12, onKill: () => {}, isRunning: () => !installed }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime( + spawner, + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), + install: Deferred.await(releaseInstall).pipe( + Effect.andThen( + Effect.sync(() => { + installed = true; + return pinnedClient; + }), + ), + ), + installWithProgress: () => Effect.die("unused"), + pruneManagedVersions: Effect.void, + }), + ), + ); + const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const; + expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 12 }); + yield* Deferred.succeed(releaseInstall, undefined); expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); }), diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 98deb8228d31..a774c7755083 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -2,6 +2,7 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay import * as RelayClient from "@t3tools/shared/relayClient"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -60,7 +61,8 @@ interface ActiveConnector { readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; readonly startedAtMillis: number; - readonly connected: Ref.Ref; + /** Completes when the connector first registers a tunnel connection. */ + readonly registered: Deferred.Deferred; } // A connector that exits before running this long is treated as part of a @@ -252,9 +254,10 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); - // Installs the pinned relay client in the background, then restarts the - // connector on it. The running connector, if any, keeps serving until the new - // binary is installed and validated, so an update costs one brief reconnect. + // Installs the pinned relay client in the background, then moves the connector + // to it. The new connector starts next to the old one on the same tunnel, and + // the old one stops only once the new one registers, so clients never see the + // relay lose this host during the swap. const installPinnedRelayClient = Effect.gen(function* () { if (yield* Ref.getAndSet(installInFlightRef, true)) return; yield* Effect.logInfo("Installing the pinned relay client", { @@ -286,17 +289,37 @@ export const make = Effect.gen(function* () { ) { return; } - yield* Effect.logInfo("Relay client installed; restarting the connector on it", { + yield* Effect.logInfo("Relay client installed; moving the connector to it", { version: installed.version, previousVersion: active?.executable.version, }); - yield* stopActive; + // Detach the old connector so the reconcile starts a new one beside it. + const previous = yield* Ref.getAndSet(activeRef, null); const status = yield* reconcileConfig(desiredConfig); - // The old connector is gone and no supervisor watches a failed spawn, - // so ask for recovery like an exited connector would. - if (status.status === "failed") { + const next = yield* Ref.get(activeRef); + if (!next) { yield* Effect.logWarning("Relay client did not start after the update", status); - yield* Queue.offer(recoveryRequests, desiredConfig); + // A still-running old connector keeps serving and the retry loop + // tries the pin again. One that exited while detached was skipped + // by its supervisor, so ask for recovery like an exited connector. + const previousRunning = previous + ? yield* previous.child.isRunning.pipe(Effect.orElseSucceed(() => false)) + : false; + if (previous && previousRunning) { + yield* Ref.set(activeRef, previous); + } else { + yield* stopConnector(previous); + yield* Queue.offer(recoveryRequests, desiredConfig); + } + return; + } + // Runs in the new connector's scope: the old one stops when the new + // one registers, or as soon as the new one is stopped or replaced. + if (previous) { + yield* Deferred.await(next.registered).pipe( + Effect.ensuring(stopConnector(previous)), + Effect.forkIn(next.scope), + ); } }), ), @@ -357,7 +380,7 @@ export const make = Effect.gen(function* () { const watchConnectorRegistration = (connector: ActiveConnector) => Effect.gen(function* () { yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT); - if (yield* Ref.get(connector.connected)) return true; + if (yield* Deferred.isDone(connector.registered)) return true; yield* Effect.logWarning( "Relay client has not registered a tunnel connection; requesting recovery", { @@ -389,7 +412,7 @@ export const make = Effect.gen(function* () { switch (classifyRelayClientOutput(line)) { case "connected": rejectedRegistrations = 0; - return Ref.set(connector.connected, true).pipe( + return Deferred.succeed(connector.registered, undefined).pipe( Effect.andThen( Effect.logInfo("Relay client tunnel connection registered", attributes), ), @@ -539,7 +562,7 @@ export const make = Effect.gen(function* () { configKey: nextConfigKey, config, startedAtMillis: yield* Clock.currentTimeMillis, - connected: yield* Ref.make(false), + registered: yield* Deferred.make(), } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index 4f3e06204164..c3597cd0642a 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -136,10 +136,11 @@ The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in [`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there (version, URLs, and checksums) is the whole release step. A linked host that starts a server with a new pin keeps its connector up on the newest older managed -release, installs the pinned one in the background, then restarts only the -connector child on it. If that restart fails to spawn, the host requests recovery -as it would for an exited connector. A host with no relay client installs one -first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the +release, installs the pinned one in the background, then starts a second +connector on it for the same tunnel. The old connector stops only after the new +one registers, so the swap never takes the host off the relay. If the new one +fails to spawn, the old one keeps serving and the install retries later. A host +with no relay client installs one first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the user's choice and is never replaced, since linking asks before downloading. Once the pinned release registers a connection, managed releases older than the From ec80933ac8cd02fec5c97b342462ccc9567cdb1e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:26:05 +0530 Subject: [PATCH 061/377] fix(connect): Cloudflare and other VPN addresses no longer show as Tailscale (#17158) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/EnvironmentRoutesSection.tsx | 1 + .../src/environment/DirectEndpoints.test.ts | 37 +++++++++ .../server/src/environment/DirectEndpoints.ts | 34 ++++++-- docs/user/remote-access.md | 13 +-- .../client-runtime/src/connection/catalog.ts | 5 ++ .../src/connection/registry.test.ts | 52 +++++++++++- .../client-runtime/src/connection/registry.ts | 25 ++++-- .../src/connection/routes.test.ts | 50 ++++++++++- .../client-runtime/src/connection/routes.ts | 82 ++++++++++++++++--- .../src/connection/supervisor.ts | 9 +- packages/contracts/src/server.ts | 4 + packages/shared/src/hostClassification.ts | 16 +++- 12 files changed, 286 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 69cc5b35df45..b20699ecb26b 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -37,6 +37,7 @@ const ROUTE_ICONS: Record = { loopback: "desktopcomputer", lan: "wifi", tailnet: "point.3.connected.trianglepath.dotted", + vpn: { ios: "lock.shield", android: "lock" }, public: "globe", ssh: "terminal", }; diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 7767158eb56d..a8f5157a65ed 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -54,6 +54,7 @@ const INTERFACES: ReturnType = { cidr: "203.0.113.20/24", }, ], + // Tailscale on macOS: an anonymous utun that also carries a Tailscale IPv6 address. utun4: [ { address: "100.101.102.103", @@ -63,9 +64,30 @@ const INTERFACES: ReturnType = { internal: false, cidr: "100.101.102.103/32", }, + { + address: "fd7a:115c:a1e0::1", + netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + family: "IPv6", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "fd7a:115c:a1e0::1/128", + scopeid: 0, + }, ], }; +/** A VPN interface with an address from the same 100.64.0.0/10 range. */ +const vpnInterface = (address: string) => [ + { + address, + netmask: "255.255.255.255", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${address}/32`, + }, +]; + const virtualInterface = (address: string) => [ { address, @@ -111,6 +133,21 @@ describe("resolveBoundEndpoints", () => { ]); }); + it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => { + const interfaces = { + en0: INTERFACES.en0, + tailscale0: vpnInterface("100.70.1.2"), + CloudflareWARP: vpnInterface("100.96.0.1"), + utun7: vpnInterface("100.85.0.1"), + }; + expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.70.1.2:3773/" }, + { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" }, + { kind: "lan", httpBaseUrl: "http://100.85.0.1:3773/" }, + ]); + }); + it("lists only the bound address for a specific bind", () => { expect( resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }), diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index d875e6d1a96f..61762fac6800 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -10,7 +10,6 @@ import type { ServerDirectEndpoint } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, - isTailscaleIpv4Address, probeTailscaleHttpsEndpoint, readTailscaleStatus, } from "@t3tools/tailscale"; @@ -24,7 +23,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; -import { isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; +import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification"; import * as ServerConfig from "../config.ts"; import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; @@ -46,16 +45,17 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} /** - * Only numeric private-network and tailnet IPv4 addresses are reported. These - * routes are plain HTTP and carry the client's credential, so a public address - * would send it across the internet unencrypted, and a name (`server.local`) - * can resolve to a different machine on each client's network. + * Only numeric private-network IPv4 addresses, including VPN ranges such as + * 100.64.0.0/10, are reported. These routes are plain HTTP and carry the + * client's credential, so a public address would send it across the internet + * unencrypted, and a name (`server.local`) can resolve to a different machine + * on each client's network. */ const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && !address.startsWith("169.254.") && - (isTailscaleIpv4Address(address) || isPrivateNetworkHost(address)); + isPrivateNetworkHost(address); /** * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and @@ -66,6 +66,19 @@ const isAdvertisableAddress = (address: string): boolean => const VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/; +/** + * Tailscale's own interface: `tailscale0` on Linux, "Tailscale" on Windows. + * macOS names every tunnel `utunN`, so there it is the one that also carries + * an address in Tailscale's IPv6 range. Cloudflare WARP and other VPNs assign + * from the same IPv4 range, so the IPv4 address alone proves nothing. + */ +const isTailscaleInterface = ( + name: string, + entries: ReadonlyArray, +): boolean => + /^tailscale/i.test(name) || + entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); + /** * Plain HTTP endpoints for the private addresses a server bound to `host` * accepts. IPv4 only: link-local and temporary IPv6 addresses change too @@ -77,6 +90,11 @@ export function resolveBoundEndpoints(input: { readonly interfaces: NetworkInterfacesMap; }): ReadonlyArray { if (isLoopbackHost(input.host)) return []; + const tailscaleAddresses = new Set( + Object.entries(input.interfaces).flatMap(([name, entries = []]) => + isTailscaleInterface(name, entries) ? entries.map((entry) => entry.address) : [], + ), + ); const addresses = isWildcardHost(input.host) ? Object.entries(input.interfaces) .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []))) @@ -87,7 +105,7 @@ export function resolveBoundEndpoints(input: { .map((entry) => entry.address) : [input.host!].filter(isAdvertisableAddress); return [...new Set(addresses)].map((address) => ({ - kind: isTailscaleIpv4Address(address) ? "tailnet" : "lan", + kind: tailscaleAddresses.has(address) ? "tailnet" : "lan", httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, })); } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 73062245c9ba..231823f13dcc 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -64,11 +64,14 @@ another link to share. ### Reach one machine several ways -A machine can have more than one route: LAN, Tailscale, a public URL, SSH, or -T3 Connect. To add one, choose **Add route** in the machine's route list, or -next to it in the T3 Connect list. Pairing the same machine again over another -address also adds a route instead of a second machine. A new route is placed by -speed, in that order, and you can reorder routes at any time. +A machine can have more than one route: LAN, Tailscale, another VPN, a public +URL, SSH, or T3 Connect. Tailscale shares its `100.64.0.0/10` address range with +other VPNs such as Cloudflare WARP, so an address in that range shows as VPN +unless the machine confirms it is on Tailscale. To add a route, choose **Add +route** in the machine's route list, or next to it in the T3 Connect list. +Pairing the same machine again over another address also adds a route instead +of a second machine. A new route is placed by speed, in that order, and you can +reorder routes at any time. While connected through T3 Connect or a paired address, T3 Code also learns the machine's current LAN and Tailscale addresses and adds them as routes, so diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 88896e78a895..b0b1526a4507 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -33,6 +33,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass Effect.Effect; readonly initialDisabled?: ReadonlyArray; + /** Direct addresses the connected server reports. */ + readonly directEndpoints?: ServerConfig["directEndpoints"]; }, ) { const storedTargets = yield* Ref.make>(initialTargets); @@ -409,7 +412,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const session = yield* Effect.acquireRelease( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], - initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + initialConfig: + options?.directEndpoints === undefined + ? Effect.die(new Error("Config is not used by registry tests.")) + : Effect.succeed({ directEndpoints: options.directEndpoints } as ServerConfig), subscribeServerConfig: () => Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, @@ -2067,6 +2073,48 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("saves the Tailscale mark when the server reports a learned address as tailnet", () => + Effect.gen(function* () { + const httpBaseUrl = "http://100.101.102.103:3773/"; + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.101.102.103:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + // Learned by an earlier build, which saved no network. + const learnedProfile = new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl, + wsBaseUrl: "ws://100.101.102.103:3773/", + learned: true, + }); + const harness = yield* makeHarness( + [LAN_TARGET, learned], + [LAN_PROFILE, learnedProfile], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + { + directEndpoints: [ + { kind: "lan", httpBaseUrl: LAN_PROFILE.httpBaseUrl }, + { kind: "tailnet", httpBaseUrl }, + ], + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* SubscriptionRef.changes(registry.entries).pipe( + Stream.map((entries) => entries.get(LAN_TARGET.environmentId)?.alternateRoutes?.[0]), + Stream.filter((route) => route !== undefined && connectionRouteKind(route) === "tailnet"), + Stream.runHead, + ); + expect((yield* Ref.get(harness.storedProfiles)).get(learnedId)).toMatchObject({ + network: "tailscale", + learned: true, + httpBaseUrl, + }); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("drops a learned route whose profile was not saved instead of duplicating it", () => Effect.gen(function* () { const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`; diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 1efc0548de89..9b48e6046303 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -49,10 +49,10 @@ import { connectionRoutes, entryWithRoutes, findRouteToSameAddress, - isLearned, mergeLearnedRoutes, routesAfterRemoving, upsertRoute, + type ReportedEndpoint, } from "./routes.ts"; const isSshConnectionProfile = Schema.is(SshConnectionProfile); @@ -891,7 +891,7 @@ export const make = Effect.gen(function* () { const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { readonly environmentId: EnvironmentId; readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; }) { return yield* withLeaseLock( input.environmentId, @@ -909,15 +909,24 @@ export const make = Effect.gen(function* () { }); if (routes === null) return Option.none(); const next = entryWithRoutes(entry, routes); - // A learned route owns its profile (address and authorization); the - // credential stays with the route it borrows from. - const previousIds = new Set( - connectionRoutes(entry).map((route) => connectionRouteId(route.target)), + // Save new learned profiles and any whose Tailscale mark changed. A + // learned route owns its profile; the credential stays with the route + // it borrows from. + const previousProfiles = new Map( + connectionRoutes(entry).map((route) => [ + connectionRouteId(route.target), + Option.getOrNull(route.profile), + ]), ); for (const route of routes) { - if (!isLearned(route) || previousIds.has(connectionRouteId(route.target))) continue; const profile = Option.getOrNull(route.profile); - if (profile !== null) yield* profiles.put(profile); + if ( + profile === null || + previousProfiles.get(connectionRouteId(route.target)) === profile + ) { + continue; + } + yield* profiles.put(profile); } yield* registrations.setRoutes(input.environmentId, persistedRoutes(next)); // Update the lease in place: the live session already works, and diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 19b433f95738..3079294305c7 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -54,7 +54,9 @@ const PUBLIC = direct("public", "https://desk.example.com/"); describe("connection routes", () => { it("classifies direct routes by address", () => { expect(connectionRouteKind(LAN)).toBe("lan"); - expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("tailnet"); + // Tailscale, Cloudflare WARP and Mesh, and other VPNs share 100.64.0.0/10. + expect(connectionRouteKind(direct("ip", "http://100.101.102.103:3773/"))).toBe("vpn"); + expect(connectionRouteLabel(direct("mesh", "http://100.96.0.1:3773/"))).toBe("VPN"); expect(connectionRouteKind(TAILNET)).toBe("tailnet"); expect(connectionRouteKind(PUBLIC)).toBe("public"); expect(connectionRouteKind(direct("lo", "http://127.0.0.1:3773/"))).toBe("loopback"); @@ -156,6 +158,52 @@ describe("learned routes", () => { ]); }); + it("labels a learned address Tailscale only while the server reports it as tailnet", () => { + const tailscale = { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }; + const mesh = { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" }; + const first = mergeLearnedRoutes({ + entry: relayOnly, + activeRoute: RELAY, + reported: [tailscale, mesh], + allowInsecure: true, + })!; + expect(first.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); + + // The server later finds the address is not on its Tailscale interface. + const corrected = mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, first), + activeRoute: RELAY, + reported: [{ ...tailscale, kind: "lan" }, mesh], + allowInsecure: true, + })!; + expect(ids(corrected)).toEqual(ids(first)); + expect(corrected.map(connectionRouteLabel)).toEqual(["VPN", "VPN", "T3 Connect"]); + expect( + mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, corrected), + activeRoute: RELAY, + reported: [{ ...tailscale, kind: "lan" }, mesh], + allowInsecure: true, + }), + ).toBeNull(); + }); + + it("labels a paired numeric Tailscale address Tailscale once the server confirms it", () => { + const paired = direct("paired", "http://100.101.102.103:3773/"); + const other = direct("other", "http://100.96.0.1:3773/"); + const entry = entryWithRoutes(relayOnly, [paired, other, RELAY]); + expect(connectionRouteLabel(paired)).toBe("VPN"); + const confirmed = mergeLearnedRoutes({ + entry, + activeRoute: paired, + reported: [{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }], + allowInsecure: true, + })!; + // Same routes in the same order; only the label of the confirmed one changes. + expect(ids(confirmed)).toEqual(["paired", "other", "relay"]); + expect(confirmed.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); + }); + it("leaves user routes alone and does not learn an address already saved", () => { const entry: ConnectionCatalogEntry = { target: LAN.target, diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 8b2ebf3e0743..4691aa9389d8 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -1,6 +1,7 @@ import { isLocalLoopbackHost, isPrivateNetworkHost, + isSharedAddressSpaceHost, isTailnetHost, } from "@t3tools/shared/hostClassification"; import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; @@ -15,12 +16,19 @@ import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; /** * A saved environment can hold several routes: T3 Connect, direct URLs (LAN, - * tailnet, public), and SSH. The client connects over the first route in + * tailnet, other VPNs, public), and SSH. The client connects over the first route in * preference order that answers as the expected environment, and moves back * to a better one when it becomes reachable again. */ -export type ConnectionRouteKind = "relay" | "loopback" | "lan" | "tailnet" | "public" | "ssh"; +export type ConnectionRouteKind = + | "relay" + | "loopback" + | "lan" + | "tailnet" + | "vpn" + | "public" + | "ssh"; /** An environment has at most one T3 Connect route, so it needs no per-route id. */ export const RELAY_ROUTE_ID = "relay"; @@ -96,7 +104,11 @@ export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind const hostname = routeHostname(route); if (hostname === null) return "public"; if (isLocalLoopbackHost(hostname)) return "loopback"; - if (isTailnetHost(hostname)) return "tailnet"; + const profile = Option.getOrNull(route.profile); + const tailscale = + profile?._tag === "BearerConnectionProfile" && profile.network === "tailscale"; + if (tailscale || isTailnetHost(hostname)) return "tailnet"; + if (isSharedAddressSpaceHost(hostname)) return "vpn"; return isPrivateNetworkHost(hostname) ? "lan" : "public"; } } @@ -106,6 +118,7 @@ const ROUTE_KIND_RANK: Record = { loopback: 0, lan: 1, tailnet: 2, + vpn: 2, public: 3, ssh: 4, relay: 5, @@ -166,7 +179,7 @@ function routeAddressKey(route: ConnectionRoute): string | null { } } -/** Short user-facing route description: "LAN", "Tailscale", "T3 Connect", a URL, or an SSH host. */ +/** Short user-facing route description: "LAN", "Tailscale", "VPN", "T3 Connect", a URL, or an SSH host. */ export function connectionRouteLabel(route: ConnectionRoute): string { switch (connectionRouteKind(route)) { case "relay": @@ -177,6 +190,8 @@ export function connectionRouteLabel(route: ConnectionRoute): string { return "LAN"; case "tailnet": return "Tailscale"; + case "vpn": + return "VPN"; case "ssh": { const profile = Option.getOrNull(route.profile); return profile?._tag === "SshConnectionProfile" @@ -212,13 +227,16 @@ export function hasRelayRoute( * in use: the paired token for a bearer route, the T3 Connect credential for * relay. A learned route the server still reports keeps its place, so the * user's order holds; one it no longer reports is dropped, so a changed LAN - * address replaces the old one. Routes the user saved are never touched, and - * an address already saved is not learned twice. + * address replaces the old one. Routes the user saved are never removed or + * moved, and an address already saved is not learned twice. Any direct route + * the server reports is marked as Tailscale while the server finds it on its + * Tailscale interface, so a route paired by its numeric Tailscale address keeps + * that label. */ export function mergeLearnedRoutes(input: { readonly entry: ConnectionCatalogEntry; readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */ readonly allowInsecure: boolean; }): ReadonlyArray | null { @@ -245,6 +263,7 @@ export function mergeLearnedRoutes(input: { // Usable reported addresses, by origin. const reported = new Map(); + const tailscaleOrigins = new Set(); for (const endpoint of input.reported) { let url: URL; try { @@ -257,6 +276,7 @@ export function mergeLearnedRoutes(input: { // A loopback address names whichever device opens it, never the server. if (isLocalLoopbackHost(url.hostname)) continue; reported.set(url.origin, url); + if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin); } const normalized = (url: string) => url.replace(/\/+$/, ""); const known = new Set( @@ -265,12 +285,18 @@ export function mergeLearnedRoutes(input: { return url === null || isLearned(route) ? [] : [normalized(url)]; }), ); - const kept = saved.filter((route) => { - if (!isLearned(route)) return true; + const kept = saved.flatMap((route): ReadonlyArray => { const url = routeHttpBaseUrl(route); - if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return false; + if (!isLearned(route)) { + return [ + url === null || !reported.has(normalized(url)) + ? route + : withNetwork(route, tailscaleOrigins.has(normalized(url))), + ]; + } + if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return []; known.add(normalized(url)); - return true; + return [withNetwork(route, tailscaleOrigins.has(normalized(url)))]; }); let next: ReadonlyArray = kept; for (const url of reported.values()) { @@ -296,6 +322,7 @@ export function mergeLearnedRoutes(input: { wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`, learned: true, ...(authorization === undefined ? {} : { authorization }), + ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}), }), ), }); @@ -303,11 +330,42 @@ export function mergeLearnedRoutes(input: { // Compare addresses too: a scheme or port change keeps no id stable. const signature = (routes: ReadonlyArray) => routes - .map((route) => `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""}`) + .map( + (route) => + `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`, + ) .join("\n"); return signature(saved) === signature(next) ? null : next; } +/** A direct address the server reports, as `ServerDirectEndpoint`. */ +export interface ReportedEndpoint { + readonly httpBaseUrl: string; + readonly kind?: string; +} + +/** The direct route with its Tailscale mark set or cleared; the same route when unchanged. */ +function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute { + const profile = Option.getOrNull(route.profile); + if (profile?._tag !== "BearerConnectionProfile") return route; + if ((profile.network === "tailscale") === tailscale) return route; + return { + target: route.target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + ...(profile.learned === undefined ? {} : { learned: profile.learned }), + ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }), + ...(tailscale ? { network: "tailscale" as const } : {}), + }), + ), + }; +} + /** * A learned bearer route borrows the credential of the route it was learned * from, so its id points back at that credential's owner. diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b710718e32ad..67f8daae691a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -30,7 +30,12 @@ import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { NETWORK_BLOCKING_HINT } from "../errors/network.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -import { connectionRouteId, connectionRoutes, entryWithRoutes } from "./routes.ts"; +import { + connectionRouteId, + connectionRoutes, + entryWithRoutes, + type ReportedEndpoint, +} from "./routes.ts"; const RETRY_BASE_DELAY_MS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; @@ -120,7 +125,7 @@ export interface EnvironmentSupervisorOptions { */ readonly learnRoutes?: (input: { readonly activeRoute: ConnectionRoute; - readonly reported: ReadonlyArray<{ readonly httpBaseUrl: string }>; + readonly reported: ReadonlyArray; }) => Effect.Effect>; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e9473dee1df2..5855477855d1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -632,6 +632,10 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole ); } +/** + * "tailnet" when the address is on this machine's Tailscale interface, "lan" + * for any other private address, including other VPNs in 100.64.0.0/10. + */ export const ServerDirectEndpointKind = Schema.Literals(["lan", "tailnet"]); export type ServerDirectEndpointKind = typeof ServerDirectEndpointKind.Type; diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index 3f6dee4bf9da..2f0d8a119c8f 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -82,18 +82,26 @@ export const isLocalLoopbackHost = (host: string): boolean => { }; /** - * A Tailscale address: a MagicDNS name, the 100.64.0.0/10 range Tailscale - * assigns, or its IPv6 range fd7a:115c:a1e0::/48. + * A name or address only Tailscale uses: a MagicDNS name or its IPv6 range + * fd7a:115c:a1e0::/48. Its IPv4 addresses come from the shared 100.64.0.0/10 + * range, so they prove nothing on their own. */ export const isTailnetHost = (host: string): boolean => { const normalized = normalizeHostname(host); if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized); - if (parts !== null) return parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; const ipv6 = parseIpv6Address(normalized); return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48); }; +/** + * An IPv4 address in 100.64.0.0/10. Tailscale, Cloudflare WARP and Mesh, other + * VPNs, and carrier-grade NAT all assign from this range. + */ +export const isSharedAddressSpaceHost = (host: string): boolean => { + const parts = parseIpv4Address(host); + return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; +}; + export const isPrivateNetworkHost = (host: string): boolean => { const normalized = normalizeHostname(host); if ( From 2cf0ff0643e23d13658114fbf3a7e76753559755 Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Fri, 9 Oct 2026 11:44:03 -0400 Subject: [PATCH 062/377] fix(web): Local environment switch stays reachable after turning it off (#17359) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/settings/ConnectionsSettings.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index a8d9d961a6c8..f0f98df7876d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -4051,6 +4051,8 @@ export function ConnectionsSettings() { title="No environment selected" description="Connect an environment to view its settings and access." /> + {/* A turned-off local environment leaves no primary environment, so its switch lives here too. */} + {canReadRelay || canManageRelay ? ( ) : null} From c5d4d2d04cce7c7fa60ac1c51974c4328cbaa8e5 Mon Sep 17 00:00:00 2001 From: maco Date: Fri, 9 Oct 2026 17:44:37 +0200 Subject: [PATCH 063/377] fix(web): keep chat banners inside the lane beside the docked details card (#17094) Co-authored-by: macodev00 --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/index.css | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 69a204ccd8a3..214b0605c833 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11353,7 +11353,7 @@ export default function ChatView(props: ChatViewProps) {
) : null} {/* Banners overlay the timeline without changing its content height. */} -
+
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6ecd5608e39d..6e6731fd7f0d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2337,7 +2337,8 @@ code { 0.75rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) ); } - .chat-scroll-to-bottom { + .chat-scroll-to-bottom, + .chat-banner-lane { inset-inline-start: var(--chat-lane-inset-start, 0px); inset-inline-end: var(--chat-lane-inset-end, 0px); } From a947f7c099dd279108a4bdfc5e0cced6c5c30b71 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:15:07 +0530 Subject: [PATCH 064/377] fix(web): settled and snoozed lines line up with the messages above them (#17191) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/ThreadStatusLine.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ThreadStatusLine.tsx b/apps/web/src/components/chat/ThreadStatusLine.tsx index aee9ab205e31..4186b42c2190 100644 --- a/apps/web/src/components/chat/ThreadStatusLine.tsx +++ b/apps/web/src/components/chat/ThreadStatusLine.tsx @@ -17,7 +17,7 @@ export function ThreadStatusLine(props: { return (
{props.icon} {props.label} From 22ccf8a3dfc9094b4ccdb689e128c67bacbbcdf5 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 9 Oct 2026 21:21:03 +0530 Subject: [PATCH 065/377] fix(web): distinguish project filter from new project (#12113) --- apps/web/src/components/Sidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf3d14c05b0c..8a5db463884e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -72,6 +72,7 @@ import { EyeIcon, FolderIcon, GitBranchIcon, + ListFilterIcon, MessageCircleQuestionIcon, PinIcon, PinOffIcon, @@ -5030,7 +5031,7 @@ export default function Sidebar() { ) : ( - + )} Date: Fri, 9 Oct 2026 12:54:10 -0300 Subject: [PATCH 066/377] feat(web): assign a thread details panel shortcut (#16694) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/keybindings.test.ts | 22 ++++++++++---- apps/server/src/keybindings.ts | 6 ++-- apps/web/src/keybindings.test.ts | 46 +++++++++++++++++++++++++---- packages/shared/src/keybindings.ts | 1 + 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 38b6b3a9c205..f6c3cb9e8843 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -258,12 +258,16 @@ it.layer(NodeServices.layer)("keybindings", (it) => { yield* writeKeybindingsConfig(keybindingsConfigPath, [ { key: "mod+shift+y", command: "terminal.toggle" }, { key: "mod+shift+r", command: "script.run-tests.run" }, + { key: "mod+shift+b", command: "script.custom-panel.run" }, + { + key: "mod+shift+b", + command: "script.custom-composer.run", + when: "composerFocus", + }, ]); - yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - yield* keybindings.syncDefaultKeybindingsOnStartup; - }); + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const byCommand = new Map(persisted.map((entry) => [entry.command, entry])); @@ -279,6 +283,14 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.isTrue(byCommand.has(defaultRule.command), `expected ${defaultRule.command}`); } assert.isTrue(byCommand.has("script.run-tests.run")); + + const configState = yield* keybindings.loadConfigState; + assert.deepEqual( + configState.keybindings + .filter((entry) => entry.shortcut.key === "b" && entry.shortcut.shiftKey) + .map((entry) => entry.command), + ["threadPanel.toggle", "script.custom-panel.run", "script.custom-composer.run"], + ); }).pipe(Effect.provide(layerKeybindings())), ); @@ -295,8 +307,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { yield* keybindings.syncDefaultKeybindingsOnStartup; assert.deepStrictEqual(yield* backgroundRules, [ - existing, { key: "mod+enter", command: "composer.sendBackground", when }, + existing, ]); // Removing the added rule later must survive the next startup. diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 332aa7134bd0..496a5852979a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -592,8 +592,8 @@ const make = Effect.gen(function* () { } } - // Startup backfill must never evict persisted user rules: append only - // the defaults that fit and skip the rest. + // Keep only defaults that fit, before persisted rules so existing + // custom shortcuts retain priority when their conditions overlap. const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); const defaultsToAppend = missingDefaults.slice(0, availableSlots); const skippedDefaults = missingDefaults.slice(availableSlots); @@ -605,7 +605,7 @@ const make = Effect.gen(function* () { }); } if (defaultsToAppend.length > 0) { - yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); + yield* writeConfigAtomically([...defaultsToAppend, ...customConfig]); } // A late default skipped at max entries stays pending for a later start. const settledLateDefaults = pendingLateDefaults.filter( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 73b1fa8fc00c..d1e842205efb 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -748,16 +748,52 @@ describe("chat/editor shortcuts", () => { ); }); - it("resolves a user-configured thread panel shortcut without assigning a default", () => { - const bindings = compile([ - { shortcut: modShortcut("b", { shiftKey: true }), command: "threadPanel.toggle" }, - ]); + it("toggles the thread panel by default without taking terminal keystrokes", () => { + for (const platform of ["MacIntel", "Win32", "Linux"]) { + const input = event({ + key: "b", + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + shiftKey: true, + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: false, editableFocus: true }, + }), + "threadPanel.toggle", + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: true }, + }), + ); + } + }); + + it("preserves a user-configured thread panel shortcut instead of its default", () => { + const bindings = mergeWithDefaultKeybindings( + compile([ + { + shortcut: modShortcut("b", { altKey: true, shiftKey: true }), + command: "threadPanel.toggle", + }, + ]), + ); assert.strictEqual( + resolveShortcutCommand( + event({ key: "b", metaKey: true, altKey: true, shiftKey: true }), + bindings, + { platform: "MacIntel" }, + ), + "threadPanel.toggle", + ); + assert.isNull( resolveShortcutCommand(event({ key: "b", metaKey: true, shiftKey: true }), bindings, { platform: "MacIntel", }), - "threadPanel.toggle", ); }); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0e95775fbb37..7620b2432a77 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -126,6 +126,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, + { key: "mod+shift+b", command: "threadPanel.toggle", when: "!terminalFocus" }, { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, From a6698271a41a4c1f2c42f25ca5d69cd7d1fa9c30 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:56:28 -0700 Subject: [PATCH 067/377] fix(web): chat content keeps pace with sidebar resizing (#17383) --- apps/web/src/components/chat/ChatCanvas.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 4df7ad66deb7..c66e42f0607f 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -7,6 +7,7 @@ import { type ComponentProps, type CSSProperties, } from "react"; +import { flushSync } from "react-dom"; import { ChatCanvasContext } from "./ChatCanvasContext"; import { resolveChatCanvasLayout, type ChatCanvasPreview } from "./chatCanvasLayout"; import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLayout"; @@ -90,7 +91,7 @@ export function ChatCanvas({ ); }; measure(); - const observer = new ResizeObserver(measure); + const observer = new ResizeObserver(() => flushSync(measure)); observer.observe(element); observer.observe(probe); if (composerOverlayElement) observer.observe(composerOverlayElement); From 2c1915b3193f16e2493709b480e6622645d0ca4e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 09:15:21 -0700 Subject: [PATCH 068/377] refactor(provider-core): expose model metadata through a ModelCatalog port (#17417) Co-authored-by: Claude Opus 5.5 --- .../src/provider/ClaudeModelCatalog.test.ts | 18 +- .../server/src/provider/ClaudeModelCatalog.ts | 33 ++- .../src/provider/Drivers/AntigravityDriver.ts | 19 +- .../src/provider/Drivers/ClaudeDriver.ts | 24 +- .../src/provider/Drivers/CodexDriver.ts | 20 +- .../server/src/provider/ModelManifest.test.ts | 224 +----------------- apps/server/src/provider/ModelManifest.ts | 216 +++-------------- .../src/provider/codexModelCatalog.test.ts | 91 +++++++ apps/server/src/provider/codexModelCatalog.ts | 65 +++++ apps/server/src/server.ts | 12 +- docs/internals/model-manifest.md | 5 +- packages/provider-core/package.json | 4 + .../src/server/ModelCatalog.test.ts | 126 ++++++++++ .../provider-core/src/server/ModelCatalog.ts | 171 +++++++++++++ 14 files changed, 579 insertions(+), 449 deletions(-) create mode 100644 apps/server/src/provider/codexModelCatalog.test.ts create mode 100644 apps/server/src/provider/codexModelCatalog.ts create mode 100644 packages/provider-core/src/server/ModelCatalog.test.ts create mode 100644 packages/provider-core/src/server/ModelCatalog.ts diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 5f44c317f0e6..40fe4dec8991 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,8 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; -import type { ModelManifestData } from "./ModelManifest.ts"; +import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, @@ -71,11 +71,17 @@ const manifest = (): ModelManifestData => ({ }, }); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); + +/** Resolves Claude's catalog the way the driver does: through its manifest catalog entry. */ +const catalogFromManifest = (data: ModelManifestData) => + resolveClaudeModelCatalog(resolveProviderCatalog(data, CLAUDE) ?? undefined); + describe("Claude model catalog", () => { it("resolves capacity from selected options and fixed catalog windows without guessing custom models", () => { const source = manifest(); const profile = source.providers!.claudeAgent!.profiles.synthetic!; - const catalog = resolveClaudeModelCatalog({ + const catalog = catalogFromManifest({ ...source, providers: { claudeAgent: { @@ -121,7 +127,7 @@ describe("Claude model catalog", () => { }); it("filters models at runtime-version boundaries and derives the upgrade message", () => { - const catalog = resolveClaudeModelCatalog(manifest()); + const catalog = catalogFromManifest(manifest()); assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); assert.deepStrictEqual( resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), @@ -157,7 +163,7 @@ describe("Claude model catalog", () => { }, }, }; - const catalog = resolveClaudeModelCatalog(input); + const catalog = catalogFromManifest(input); assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); assert.strictEqual( resolveClaudeModelSlug(catalog, "claude-synthetic-next"), @@ -195,7 +201,7 @@ describe("Claude model catalog", () => { }); it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { - const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ + const catalog = scopeClaudeModelCatalog(catalogFromManifest(manifest()), [ "synthetic", { slug: "claude-custom-tuned", diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 14d321d792b3..659dc188d4a9 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -21,11 +21,8 @@ import { decodeClaudeModelAdapter, decodeClaudeProfileAdapter, } from "./ClaudeModelManifest.ts"; -import { - BUNDLED_MODEL_MANIFEST, - type ModelManifestData, - resolveProviderCatalog, -} from "./ModelManifest.ts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; +import { BUNDLED_MODEL_MANIFEST, resolveProviderCatalog } from "./ModelManifest.ts"; const CLAUDE = ProviderDriverKind.make("claudeAgent"); const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; @@ -40,8 +37,9 @@ export interface ClaudeModelCatalog { readonly models: ReadonlyArray; } -function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { - const resolved = resolveProviderCatalog(manifest, CLAUDE); +function tryResolveClaudeModelCatalog( + resolved: ModelCatalog.ProviderCatalog | undefined, +): ClaudeModelCatalog | null { if (!resolved) return null; const models: Array = []; @@ -50,7 +48,7 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); if (Option.isNone(profile) || Option.isNone(adapter)) return null; models.push({ - model: entry.model, + model: ModelCatalog.catalogServerModel(resolved, entry), runtime: profile.value.claudeCode ?? {}, compatibility: adapter.value.claudeCode ?? {}, }); @@ -61,16 +59,27 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC }; } -export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { +const BUNDLED_CLAUDE_PROVIDER_CATALOG = + resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined; + +/** + * Claude's built-in catalog from its manifest entry, falling back to the + * bundled entry when the current one is missing or has invalid adapters. + */ +export function resolveClaudeModelCatalog( + catalog: ModelCatalog.ProviderCatalog | undefined, +): ClaudeModelCatalog { return ( - tryResolveClaudeModelCatalog(manifest) ?? - tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { + tryResolveClaudeModelCatalog(catalog) ?? + tryResolveClaudeModelCatalog(BUNDLED_CLAUDE_PROVIDER_CATALOG) ?? { models: [], } ); } -export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog( + BUNDLED_CLAUDE_PROVIDER_CATALOG, +); /** * Scope the catalog to one instance's settings: custom model slugs stay opaque diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fe2f3f301a58..d8710e901a4d 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -52,7 +52,7 @@ import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeL import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import * as ModelManifest from "../ModelManifest.ts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -73,7 +73,7 @@ export type AntigravityDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 - | ModelManifest.ModelManifest + | ModelCatalog.ModelCatalog | Path.Path | ProviderEventLoggers.ProviderEventLoggers; @@ -93,7 +93,8 @@ export const AntigravityDriver: ProviderDriver - modelManifest.current.pipe( - Effect.map((manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)), - ), + currentCatalog.pipe( + Effect.map((catalog) => stampIdentity(ModelCatalog.applyModelCatalog(draft, catalog))), ); const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( @@ -336,7 +335,7 @@ export const AntigravityDriver: ProviderDriver ModelManifest.manifestDefaultModel(manifest, DRIVER)), - ); + const defaultModel = currentCatalog.pipe(Effect.map((catalog) => catalog?.defaultChatModel)); const orchestrationAdapter = yield* makeAntigravityAdapterV2({ instanceId, selfInvocation, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index aea45e37cb9b..18ce1e481efc 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -43,7 +43,7 @@ import { probeClaudeWorkspaceSnapshot, } from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import * as ModelManifest from "../ModelManifest.ts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { defaultProviderContinuationIdentity, @@ -100,7 +100,7 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient - | ModelManifest.ModelManifest + | ModelCatalog.ModelCatalog | Path.Path | ProviderEventLoggers.ProviderEventLoggers | ServerConfig.ServerConfig; @@ -122,8 +122,10 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const host = yield* ProviderHost; - const modelManifest = yield* ModelManifest.ModelManifest; - const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); + const catalogService = yield* ModelCatalog.ModelCatalog; + const modelCatalog = catalogService + .current(DRIVER_KIND) + .pipe(Effect.map(resolveClaudeModelCatalog)); const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -208,16 +210,16 @@ export const ClaudeDriver: ProviderDriver = { // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. - const checkProvider = modelManifest.refreshInBackground.pipe( + const checkProvider = catalogService.refreshInBackground.pipe( Effect.andThen( - modelManifest.current.pipe( - Effect.flatMap((manifest) => + modelCatalog.pipe( + Effect.flatMap((catalog) => checkClaudeProviderStatus( effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, cwd, - resolveClaudeModelCatalog(manifest), + catalog, scopedLimitNames, (version) => ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( @@ -242,10 +244,8 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - modelManifest.current.pipe( - Effect.flatMap((manifest) => - makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), - ), + modelCatalog.pipe( + Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)), Effect.map(stampIdentity), ), checkProvider, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index b007785a6e82..a2e580330c3c 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -49,7 +49,8 @@ import { } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import * as ModelManifest from "../ModelManifest.ts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; +import { applyCodexModelCatalog } from "../codexModelCatalog.ts"; import type { ProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -114,7 +115,7 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient - | ModelManifest.ModelManifest + | ModelCatalog.ModelCatalog | Path.Path | ProviderEventLoggers.ProviderEventLoggers | ServerConfig.ServerConfig @@ -147,7 +148,8 @@ export const CodexDriver: ProviderDriver = { const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const host = yield* ProviderHost; - const modelManifest = yield* ModelManifest.ModelManifest; + const modelCatalog = yield* ModelCatalog.ModelCatalog; + const currentCatalog = modelCatalog.current(DRIVER_KIND); const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -218,13 +220,12 @@ export const CodexDriver: ProviderDriver = { // Kick the TTL-gated manifest refresh in the background and classify // with the in-memory manifest, so a slow or hung fetch never delays the // provider check. A refresh that lands mid-probe applies on the next one. - const checkProvider = modelManifest.refreshInBackground.pipe( + const checkProvider = modelCatalog.refreshInBackground.pipe( Effect.andThen( Effect.zipWith( checkCodexProviderStatus(effectiveConfig, undefined, processEnv), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), { concurrent: true }, ), ), @@ -239,9 +240,8 @@ export const CodexDriver: ProviderDriver = { initialSnapshot: (settings) => Effect.zipWith( makePendingCodexProvider(settings.provider), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + currentCatalog, + (draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)), ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 666281a8ac5b..e472229dd0e0 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -20,212 +20,6 @@ import * as ModelManifest from "./ModelManifest.ts"; * synthetic models for resolver coverage. */ -const CODEX = ProviderDriverKind.make("codex"); -const model = (overrides: Partial): ServerProviderModel => ({ - slug: "gpt-test", - name: "GPT Test", - isCustom: false, - capabilities: null, - ...overrides, -}); - -describe("classifyModels", () => { - it("classifies qualified Codex families without changing their wire ids", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: { codex: ["gpt-test"] }, - providers: { - codex: { - profiles: {}, - models: [{ slug: "gpt-old", name: "Old", status: "legacy" }], - }, - }, - }; - const models = [ - model({ slug: "openai.gpt-test", isLegacy: true }), - model({ slug: "openai.gpt-old" }), - ]; - assert.deepStrictEqual( - ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [ - entry.slug, - entry.isLegacy ?? false, - ]), - [ - ["openai.gpt-test", false], - ["openai.gpt-old", true], - ], - ); - }); - it("flags only known legacy models, clears stale flags, and skips custom models", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: { codex: ["current-a", "current-b"] }, - providers: { - codex: { - profiles: {}, - models: [{ slug: "old-model", name: "Old", status: "legacy" }], - }, - }, - }; - const models = [ - model({ slug: "current-a" }), - // Stale flag from a previous classification pass must be cleared. - model({ slug: "current-b", isLegacy: true }), - model({ slug: "old-model" }), - model({ slug: "new-release", isLegacy: true }), - // Custom models are user-defined and never reclassified. - model({ slug: "my-own-model", isCustom: true }), - ]; - assert.deepStrictEqual( - ModelManifest.classifyModels(models, manifest, CODEX).map((entry) => [ - entry.slug, - entry.isLegacy ?? false, - ]), - [ - ["current-a", false], - ["current-b", false], - ["old-model", true], - ["new-release", false], - ["my-own-model", false], - ], - ); - }); - it.each(["codex", "antigravity"])( - "keeps newly discovered %s models current when the manifest has no catalog", - (driverKind) => { - const models = [model({ slug: "new-release", isLegacy: true })]; - assert.deepStrictEqual( - ModelManifest.classifyModels( - models, - { version: 1, currentModels: { [driverKind]: ["known-current"] } }, - ProviderDriverKind.make(driverKind), - ), - [model({ slug: "new-release" })], - ); - }, - ); -}); - -describe("applyManifestDefault", () => { - it("resolves the manifest default to the qualified live model", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: {}, - providers: { codex: { models: [], profiles: {}, defaults: { chat: "gpt-test" } } }, - }; - const models = [ - model({ slug: "openai.gpt-old", isDefault: true }), - model({ slug: "openai.gpt-test" }), - ]; - assert.strictEqual( - ModelManifest.applyManifestDefault(models, manifest, CODEX).find((entry) => entry.isDefault) - ?.slug, - "openai.gpt-test", - ); - }); - it("moves the default flag and its aliases to the manifest's chat default", () => { - const driver = ProviderDriverKind.make("antigravity"); - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: {}, - providers: { - antigravity: { - defaults: { chat: "gemini-new" }, - profiles: {}, - models: [{ slug: "gemini-new", name: "New", status: "current" }], - }, - }, - }; - const models = [ - model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }), - model({ slug: "gemini-new" }), - ]; - assert.deepStrictEqual(ModelManifest.applyManifestDefault(models, manifest, driver), [ - model({ slug: "gemini-old" }), - model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }), - ]); - // The account does not offer the manifest default: keep the runtime's choice. - assert.deepStrictEqual( - ModelManifest.applyManifestDefault(models.slice(0, 1), manifest, driver), - models.slice(0, 1), - ); - }); -}); - -describe("applyModelManifest", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: {}, - providers: { - codex: { - profiles: {}, - models: [ - { - slug: "gpt-next", - name: "GPT Next", - status: "current", - badge: "new", - adapter: { codex: { minVersion: "1.2.0" } }, - }, - { slug: "gpt-unversioned", name: "GPT Unversioned", status: "current" }, - { - slug: "gpt-retired", - name: "GPT Retired", - status: "legacy", - adapter: { codex: { minVersion: "1.2.0" } }, - }, - ], - }, - }, - }; - const draft = (version: string | null, models: ReadonlyArray = []) => ({ - enabled: true, - installed: true, - version, - status: "ready" as const, - auth: { status: "authenticated" as const }, - checkedAt: "2026-01-01T00:00:00.000Z", - models, - slashCommands: [], - skills: [], - }); - - it("names current Codex models that need a newer CLI and are missing from discovery", () => { - assert.deepStrictEqual( - ModelManifest.applyModelManifest(draft("1.1.9"), manifest, CODEX).updateRequiredModels, - [{ slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }], - ); - for (const result of [ - // The CLI is new enough. - ModelManifest.applyModelManifest(draft("1.2.0"), manifest, CODEX), - // The CLI already lists the model, even under a qualified slug. - ModelManifest.applyModelManifest( - draft("1.1.9", [model({ slug: "openai.gpt-next" })]), - manifest, - CODEX, - ), - // An unknown version cannot be compared. - ModelManifest.applyModelManifest(draft(null), manifest, CODEX), - // A qualified manifest slug still matches the discovered family. - ModelManifest.applyModelManifest( - draft("1.1.9", [model({ slug: "gpt-next" })]), - { - ...manifest, - providers: { - codex: { - profiles: {}, - models: [{ ...manifest.providers!.codex!.models[0]!, slug: "openai.gpt-next" }], - }, - }, - }, - CODEX, - ), - ]) { - assert.isUndefined(result.updateRequiredModels); - } - }); -}); - describe("resolveProviderCatalog", () => { it("resolves generic model presentation through a reusable profile", () => { const manifest: ModelManifest.ModelManifestData = { @@ -268,18 +62,16 @@ describe("resolveProviderCatalog", () => { ProviderDriverKind.make("synthetic"), ); assert.deepStrictEqual(catalog?.models[0], { - model: { - slug: "model-next", - name: "Model Next", - aliases: ["next"], - badge: "new", - isCustom: false, - isDefault: true, - capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, - }, + slug: "model-next", + name: "Model Next", + aliases: ["next"], + badge: "new", + status: "current", + capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, adapter: undefined, profileAdapter: { opaque: true }, }); + assert.strictEqual(catalog?.defaultChatModel, "model-next"); }); it("rejects invalid catalog references", () => { diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 37c2bbec91b0..af8796b435ab 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -17,17 +17,12 @@ import { ModelCapabilities, TrimmedNonEmptyString, type ProviderDriverKind, - type ServerProviderModel, - type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; -import { codexModelFamily } from "@t3tools/shared/model"; -import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -39,7 +34,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; -import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; const MODEL_MANIFEST_URL = "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; @@ -126,19 +121,6 @@ const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( ); export type ModelManifestData = typeof ModelManifestSchema.Type; -export interface ResolvedManifestModel { - readonly model: ServerProviderModel; - readonly adapter: unknown; - readonly profileAdapter: unknown; -} - -export interface ResolvedProviderCatalog { - readonly models: ReadonlyArray; - readonly defaults: { - readonly chat: string | undefined; - }; -} - const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = @@ -151,16 +133,19 @@ function manifestUpdatedAtMs(manifest: ModelManifestData): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** Resolve provider-neutral model presentation and capability data. */ +/** + * A driver's catalog entry as provider packages see it, or `null` when the + * manifest has none or its references are invalid. + */ export function resolveProviderCatalog( manifest: ModelManifestData, driverKind: ProviderDriverKind, -): ResolvedProviderCatalog | null { +): ModelCatalog.ProviderCatalog | null { const catalog = manifest.providers?.[driverKind]; if (!catalog) return null; const seen = new Set(); - const models: Array = []; + const models: Array = []; for (const entry of catalog.models) { if (seen.has(entry.slug)) return null; seen.add(entry.slug); @@ -169,18 +154,14 @@ export function resolveProviderCatalog( if (entry.profile && !profile) return null; models.push({ - model: { - slug: entry.slug, - name: entry.name, - ...(entry.shortName ? { shortName: entry.shortName } : {}), - ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), - ...(entry.aliases ? { aliases: entry.aliases } : {}), - ...(entry.badge ? { badge: entry.badge } : {}), - isCustom: false, - ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), - ...(entry.status === "legacy" ? { isLegacy: true } : {}), - capabilities: profile?.capabilities ?? null, - }, + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + status: entry.status, + capabilities: profile?.capabilities ?? null, adapter: entry.adapter, profileAdapter: profile?.adapter, }); @@ -188,12 +169,7 @@ export function resolveProviderCatalog( if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; - return { - models, - defaults: { - chat: catalog.defaults?.chat, - }, - }; + return { models, defaultChatModel: catalog.defaults?.chat }; } /** On-disk shape of the last successfully fetched manifest. */ @@ -213,142 +189,6 @@ export const encodeManifestCache = Schema.encodeEffect( ), ); -/** True when the manifest classifies `slug` as legacy for `driverKind`. */ -function isLegacyModel( - manifest: ModelManifestData, - driverKind: ProviderDriverKind, - slug: string, -): boolean { - const family = driverKind === "codex" ? codexModelFamily(slug) : slug; - const catalog = manifest.providers?.[driverKind]?.models; - const catalogModel = - catalog?.find((model) => model.slug === slug) ?? - catalog?.find((model) => model.slug === family); - return catalogModel?.status === "legacy"; -} - -/** - * Reclassifies every built-in model on a snapshot draft against the manifest. - * Custom models are user-defined and never reclassified. - */ -export function applyModelManifest( - draft: ServerProviderDraft, - manifest: ModelManifestData, - driverKind: ProviderDriverKind, -): ServerProviderDraft { - const { updateRequiredModels: _previous, ...rest } = draft; - const updateRequiredModels = - driverKind === "codex" ? codexUpdateRequiredModels(manifest, draft) : []; - return { - ...rest, - models: applyManifestDefault( - classifyModels(draft.models, manifest, driverKind), - manifest, - driverKind, - ), - ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), - }; -} - -const CodexModelAdapter = Schema.Struct({ - codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), -}); -const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); - -/** - * Codex lists only the models its own build knows, so a model released after - * the installed CLI never shows up. A current manifest entry with - * `adapter.codex.minVersion` names that model, letting the picker say an update - * unlocks it instead of leaving users to wonder where it is. - */ -function codexUpdateRequiredModels( - manifest: ModelManifestData, - draft: ServerProviderDraft, -): ReadonlyArray { - const version = draft.version?.replace(/^v/, ""); - if (!version || parseSemver(version) === null) return []; - const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); - return (manifest.providers?.codex?.models ?? []).flatMap((entry) => { - if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; - const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex - ?.minVersion; - if (!minVersion || parseSemver(minVersion) === null) return []; - if (compareSemverVersions(version, minVersion) >= 0) return []; - return [ - { - slug: entry.slug, - name: entry.name, - ...(entry.badge ? { badge: entry.badge } : {}), - minVersion, - }, - ]; - }); -} - -/** The manifest's chat default for `driverKind`, when it names one. */ -export function manifestDefaultModel( - manifest: ModelManifestData, - driverKind: ProviderDriverKind, -): string | undefined { - return manifest.providers?.[driverKind]?.defaults?.chat; -} - -/** - * Moves `isDefault` to the manifest's chat default when the catalog carries - * it. Providers that learn their default from the runtime (Antigravity takes - * Google's current model) can be overridden here without a release. Aliases - * that pointed at the old default move with the flag so the shared - * "provider default" alias keeps resolving. - */ -export function applyManifestDefault( - models: ReadonlyArray, - manifest: ModelManifestData, - driverKind: ProviderDriverKind, -): ReadonlyArray { - const requestedSlug = manifestDefaultModel(manifest, driverKind); - if (requestedSlug === undefined) return models; - const slug = - models.find((model) => model.slug === requestedSlug)?.slug ?? - (driverKind === "codex" - ? models.find( - (model) => - !model.isCustom && codexModelFamily(model.slug) === codexModelFamily(requestedSlug), - )?.slug - : undefined); - if (slug === undefined) return models; - const previous = models.find((model) => model.isDefault && model.slug !== slug); - if (!previous) return models; - const movedAliases = previous.aliases ?? []; - return models.map((model) => { - if (model.slug === previous.slug) { - const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; - return rest; - } - if (model.slug === slug) { - const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; - return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; - } - return model; - }); -} - -/** Model-level half of `applyModelManifest`, exported for focused tests. */ -export function classifyModels( - models: ReadonlyArray, - manifest: ModelManifestData, - driverKind: ProviderDriverKind, -): ReadonlyArray { - return models.map((model) => { - if (model.isCustom) return model; - if (isLegacyModel(manifest, driverKind, model.slug)) { - return model.isLegacy ? model : { ...model, isLegacy: true }; - } - if (!model.isLegacy) return model; - const { isLegacy: _isLegacy, ...rest } = model; - return rest; - }); -} - export class ModelManifest extends Context.Service< ModelManifest, { @@ -374,7 +214,7 @@ const BundledOnlyModelManifest: ModelManifest["Service"] = { refreshInBackground: Effect.void, }; -export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); +const layerBundledOnly = Layer.succeed(ModelManifest, BundledOnlyModelManifest); export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -470,3 +310,25 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(ModelManifest, make); + +/** Bundled data only, with the provider catalog port drivers read; for tests. */ +export const layerTest = Layer.suspend(() => + layerModelCatalog.pipe(Layer.provideMerge(layerBundledOnly)), +); + +/** Exposes the manifest to provider packages as their per-driver catalogs. */ +export const layerModelCatalog = Layer.effect( + ModelCatalog.ModelCatalog, + Effect.gen(function* () { + const manifest = yield* ModelManifest; + return ModelCatalog.ModelCatalog.of({ + current: (driverKind) => + manifest.current.pipe( + Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined), + ), + bundled: (driverKind) => + resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined, + refreshInBackground: manifest.refreshInBackground, + }); + }), +); diff --git a/apps/server/src/provider/codexModelCatalog.test.ts b/apps/server/src/provider/codexModelCatalog.test.ts new file mode 100644 index 000000000000..e46f6a404f71 --- /dev/null +++ b/apps/server/src/provider/codexModelCatalog.test.ts @@ -0,0 +1,91 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ServerProviderModel } from "@t3tools/contracts"; +import type * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; + +import { applyCodexModelCatalog } from "./codexModelCatalog.ts"; + +/** Synthetic models only; real Codex model data lives in model-manifest.json. */ +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +const entry = ( + overrides: Partial & { readonly slug: string }, +): ModelCatalog.ProviderCatalogModel => ({ + name: overrides.slug, + status: "current", + capabilities: null, + adapter: undefined, + profileAdapter: undefined, + ...overrides, +}); + +const catalog: ModelCatalog.ProviderCatalog = { + defaultChatModel: undefined, + models: [ + entry({ + slug: "gpt-next", + name: "GPT Next", + badge: "new", + adapter: { codex: { minVersion: "1.2.0" } }, + }), + entry({ slug: "gpt-unversioned" }), + entry({ slug: "gpt-retired", status: "legacy", adapter: { codex: { minVersion: "1.2.0" } } }), + ], +}; + +const draft = (version: string | null, models: ReadonlyArray = []) => ({ + enabled: true, + installed: true, + version, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models, + slashCommands: [], + skills: [], +}); + +describe("applyCodexModelCatalog", () => { + it("names current models that need a newer Codex and are missing from discovery", () => { + assert.deepStrictEqual(applyCodexModelCatalog(draft("1.1.9"), catalog).updateRequiredModels, [ + { slug: "gpt-next", name: "GPT Next", badge: "new", minVersion: "1.2.0" }, + ]); + for (const result of [ + // The CLI is new enough. + applyCodexModelCatalog(draft("1.2.0"), catalog), + // The CLI already lists the model, even under a qualified slug. + applyCodexModelCatalog(draft("1.1.9", [model({ slug: "openai.gpt-next" })]), catalog), + // An unknown version cannot be compared. + applyCodexModelCatalog(draft(null), catalog), + // A qualified catalog slug still matches the discovered family. + applyCodexModelCatalog(draft("1.1.9", [model({ slug: "gpt-next" })]), { + ...catalog, + models: [{ ...catalog.models[0]!, slug: "openai.gpt-next" }], + }), + ]) { + assert.isUndefined(result.updateRequiredModels); + } + }); + + it("classifies qualified Codex families without changing their wire ids", () => { + const classified = applyCodexModelCatalog( + draft("1.2.0", [ + model({ slug: "openai.gpt-test", isLegacy: true }), + model({ slug: "openai.gpt-retired" }), + ]), + catalog, + ); + assert.deepStrictEqual( + classified.models.map((entry) => [entry.slug, entry.isLegacy ?? false]), + [ + ["openai.gpt-test", false], + ["openai.gpt-retired", true], + ], + ); + }); +}); diff --git a/apps/server/src/provider/codexModelCatalog.ts b/apps/server/src/provider/codexModelCatalog.ts new file mode 100644 index 000000000000..854323ab6f82 --- /dev/null +++ b/apps/server/src/provider/codexModelCatalog.ts @@ -0,0 +1,65 @@ +/** + * Codex's view of its manifest catalog. Codex discovers models from its app + * server, so the catalog only reclassifies them, moves the default, and names + * models a newer Codex build would unlock. + * + * @module provider/codexModelCatalog + */ +import type { ServerProviderUpdateRequiredModel } from "@t3tools/contracts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; +import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; +import { codexModelFamily } from "@t3tools/shared/model"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; + +/** Dated Codex builds match their base model's catalog entry. */ +const CODEX_MATCHING: ModelCatalog.CatalogMatching = { family: codexModelFamily }; + +const CodexModelAdapter = Schema.Struct({ + codex: Schema.optional(Schema.Struct({ minVersion: Schema.optional(TrimmedNonEmptyString) })), +}); +const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); + +/** + * Codex lists only the models its own build knows, so a model released after + * the installed CLI never shows up. A current catalog entry with + * `adapter.codex.minVersion` names that model, letting the picker say an update + * unlocks it instead of leaving users to wonder where it is. + */ +function codexUpdateRequiredModels( + catalog: ModelCatalog.ProviderCatalog | undefined, + draft: ServerProviderDraft, +): ReadonlyArray { + const version = draft.version?.replace(/^v/, ""); + if (!catalog || !version || parseSemver(version) === null) return []; + const discovered = new Set(draft.models.map((model) => codexModelFamily(model.slug))); + return catalog.models.flatMap((entry) => { + if (entry.status !== "current" || discovered.has(codexModelFamily(entry.slug))) return []; + const minVersion = Option.getOrUndefined(decodeCodexModelAdapter(entry.adapter ?? {}))?.codex + ?.minVersion; + if (!minVersion || parseSemver(minVersion) === null) return []; + if (compareSemverVersions(version, minVersion) >= 0) return []; + return [ + { + slug: entry.slug, + name: entry.name, + ...(entry.badge ? { badge: entry.badge } : {}), + minVersion, + }, + ]; + }); +} + +/** Reclassifies a Codex snapshot draft's models against its catalog. */ +export function applyCodexModelCatalog( + draft: ServerProviderDraft, + catalog: ModelCatalog.ProviderCatalog | undefined, +): ServerProviderDraft { + const updateRequiredModels = codexUpdateRequiredModels(catalog, draft); + return { + ...ModelCatalog.applyModelCatalog(draft, catalog, CODEX_MATCHING), + ...(updateRequiredModels.length > 0 ? { updateRequiredModels } : {}), + }; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6a698c2e46ab..79fb63afb64f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -604,11 +604,15 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( // the rewritten telemetry pipeline can account for logical NDJSON writes. // Provided once at the runtime level so every consumer sees the same // logger instances. - // `ModelManifest.layer` is the legacy-model classification data, refreshed - // from the repo's `model-manifest.json` on `main` and applied by the - // Codex/Claude drivers. + // `ModelManifest.layer` is the model manifest, refreshed from the repo's + // `model-manifest.json` on `main`. Drivers read their entry through the + // `ModelCatalog` port it provides. Layer.provideMerge( - Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), + Layer.mergeAll( + ProviderEventLoggers.layer, + ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), + ResetCreditCoordinator.layer, + ), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md index 7c2ffa595df6..9a13255c3beb 100644 --- a/docs/internals/model-manifest.md +++ b/docs/internals/model-manifest.md @@ -11,7 +11,10 @@ correct model data before the next successful fetch. Bump `updatedAt` whenever t file changes. Fetch time cannot establish which copy contains the newer edit. Generic catalog data describes presentation and capabilities. Each provider owns -its adapter schema and dispatch mappings. Claude uses the manifest for its entire +its adapter schema and dispatch mappings. Providers never read the manifest +itself: the server exposes each driver's entry through the +[`ModelCatalog`](../../packages/provider-core/src/server/ModelCatalog.ts) port, +and compatibility policies stay with the server. Claude uses the manifest for its entire built-in catalog. Adding a model with an existing capability profile is a JSON edit; a new profile is needed only for a new capability combination. Codex still gets its model list from its app server. diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index b069cc572989..fe4b25b981bc 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -75,6 +75,10 @@ "types": "./src/server/mcpToolPresentation.ts", "import": "./src/server/mcpToolPresentation.ts" }, + "./server/ModelCatalog": { + "types": "./src/server/ModelCatalog.ts", + "import": "./src/server/ModelCatalog.ts" + }, "./server/nativeProtocolLogging": { "types": "./src/server/nativeProtocolLogging.ts", "import": "./src/server/nativeProtocolLogging.ts" diff --git a/packages/provider-core/src/server/ModelCatalog.test.ts b/packages/provider-core/src/server/ModelCatalog.test.ts new file mode 100644 index 000000000000..afa041196a86 --- /dev/null +++ b/packages/provider-core/src/server/ModelCatalog.test.ts @@ -0,0 +1,126 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { ServerProviderModel } from "@t3tools/contracts"; + +import * as ModelCatalog from "./ModelCatalog.ts"; + +/** + * Covers catalog semantics with synthetic models. Real model data lives in the + * server's model-manifest.json and is not asserted here. + */ +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "model-test", + name: "Model Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +const catalogModel = ( + slug: string, + status: ModelCatalog.ProviderCatalogModel["status"], +): ModelCatalog.ProviderCatalogModel => ({ + slug, + name: slug, + status, + capabilities: null, + adapter: undefined, + profileAdapter: undefined, +}); + +/** Strips a vendor prefix, the way a driver maps qualified slugs to catalog families. */ +const prefixFamily: ModelCatalog.CatalogMatching = { + family: (slug) => slug.replace(/^vendor\./, ""), +}; + +describe("classifyCatalogModels", () => { + it("flags only known legacy models, clears stale flags, and skips custom models", () => { + const catalog: ModelCatalog.ProviderCatalog = { + models: [catalogModel("old-model", "legacy")], + defaultChatModel: undefined, + }; + const models = [ + model({ slug: "current-a" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "current-b", isLegacy: true }), + model({ slug: "old-model" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true, isLegacy: true }), + ]; + assert.deepStrictEqual( + ModelCatalog.classifyCatalogModels(models, catalog).map((entry) => [ + entry.slug, + entry.isLegacy ?? false, + ]), + [ + ["current-a", false], + ["current-b", false], + ["old-model", true], + ["my-own-model", true], + ], + ); + }); + + it("matches qualified slugs through the driver's family without changing their ids", () => { + const catalog: ModelCatalog.ProviderCatalog = { + models: [catalogModel("model-old", "legacy")], + defaultChatModel: undefined, + }; + assert.deepStrictEqual( + ModelCatalog.classifyCatalogModels( + [model({ slug: "vendor.model-test", isLegacy: true }), model({ slug: "vendor.model-old" })], + catalog, + prefixFamily, + ).map((entry) => [entry.slug, entry.isLegacy ?? false]), + [ + ["vendor.model-test", false], + ["vendor.model-old", true], + ], + ); + }); + + it("keeps discovered models current when the driver has no catalog", () => { + assert.deepStrictEqual( + ModelCatalog.classifyCatalogModels([model({ slug: "new", isLegacy: true })], undefined), + [model({ slug: "new" })], + ); + }); +}); + +describe("applyCatalogDefault", () => { + it("moves the default flag and its aliases to the catalog's chat default", () => { + const catalog: ModelCatalog.ProviderCatalog = { + models: [catalogModel("model-new", "current")], + defaultChatModel: "model-new", + }; + const models = [ + model({ slug: "model-old", isDefault: true, aliases: ["provider-default"] }), + model({ slug: "model-new" }), + ]; + assert.deepStrictEqual(ModelCatalog.applyCatalogDefault(models, catalog), [ + model({ slug: "model-old" }), + model({ slug: "model-new", isDefault: true, aliases: ["provider-default"] }), + ]); + // The account does not offer the catalog default: keep the runtime's choice. + assert.deepStrictEqual( + ModelCatalog.applyCatalogDefault(models.slice(0, 1), catalog), + models.slice(0, 1), + ); + }); + + it("resolves the catalog default to a qualified discovered model", () => { + const catalog: ModelCatalog.ProviderCatalog = { + models: [], + defaultChatModel: "model-test", + }; + const models = [ + model({ slug: "vendor.model-old", isDefault: true }), + model({ slug: "vendor.model-test" }), + ]; + assert.strictEqual( + ModelCatalog.applyCatalogDefault(models, catalog, prefixFamily).find( + (entry) => entry.isDefault, + )?.slug, + "vendor.model-test", + ); + }); +}); diff --git a/packages/provider-core/src/server/ModelCatalog.ts b/packages/provider-core/src/server/ModelCatalog.ts new file mode 100644 index 000000000000..d241058258c8 --- /dev/null +++ b/packages/provider-core/src/server/ModelCatalog.ts @@ -0,0 +1,171 @@ +/** + * ModelCatalog — the model metadata a provider reads from T3's model manifest. + * + * The server owns the manifest itself (bundle, remote refresh, disk cache, + * compatibility policies). A provider only sees its own catalog entry, so a + * provider package never depends on the manifest file or how it is fetched. + * Reads never wait on the network. + * + * @module provider-core/server/ModelCatalog + */ +import type { + ModelCapabilities, + ProviderDriverKind, + ServerProviderModel, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ServerProviderDraft } from "./snapshotProbe.ts"; + +/** One model entry in a provider's manifest catalog. */ +export interface ProviderCatalogModel { + readonly slug: string; + readonly name: string; + readonly shortName?: string; + readonly subProvider?: string; + readonly aliases?: ReadonlyArray; + readonly status: "current" | "legacy"; + readonly badge?: "new"; + readonly capabilities: ModelCapabilities | null; + /** Provider-owned metadata for this model; the provider decodes it. */ + readonly adapter: unknown; + /** Provider-owned metadata for the model's capability profile. */ + readonly profileAdapter: unknown; +} + +/** A provider's catalog entry: its models and the chat default. */ +export interface ProviderCatalog { + readonly models: ReadonlyArray; + readonly defaultChatModel: string | undefined; +} + +export class ModelCatalog extends Context.Service< + ModelCatalog, + { + /** + * The driver's catalog from the manifest in memory (disk cache or bundle), + * or `undefined` when the manifest has no entry for it. Never fetches. + */ + readonly current: ( + driverKind: ProviderDriverKind, + ) => Effect.Effect; + /** The same catalog from the bundled manifest; for fallbacks when remote data is invalid. */ + readonly bundled: (driverKind: ProviderDriverKind) => ProviderCatalog | undefined; + /** + * Starts a TTL-gated remote refresh in the background. Provider checks call + * this; it never fails and never blocks the check. + */ + readonly refreshInBackground: Effect.Effect; + } +>()("@t3tools/provider-core/server/ModelCatalog") {} + +/** A catalog model as the snapshot model it presents. */ +export const catalogServerModel = ( + catalog: ProviderCatalog, + entry: ProviderCatalogModel, +): ServerProviderModel => ({ + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + isCustom: false, + ...(catalog.defaultChatModel === entry.slug ? { isDefault: true } : {}), + ...(entry.status === "legacy" ? { isLegacy: true } : {}), + capabilities: entry.capabilities, +}); + +/** How a driver matches its discovered slugs to catalog entries. */ +export interface CatalogMatching { + /** + * Maps a slug to the family the catalog names it by, such as a dated Codex + * build to its base model. Defaults to the slug itself. + */ + readonly family?: (slug: string) => string; +} + +const familyOf = (matching: CatalogMatching | undefined, slug: string) => + matching?.family?.(slug) ?? slug; + +/** + * Reclassifies built-in models as legacy or current from the catalog. Custom + * models are user-defined and never reclassified. + */ +export function classifyCatalogModels( + models: ReadonlyArray, + catalog: ProviderCatalog | undefined, + matching?: CatalogMatching, +): ReadonlyArray { + return models.map((model) => { + if (model.isCustom) return model; + const entry = + catalog?.models.find((candidate) => candidate.slug === model.slug) ?? + catalog?.models.find((candidate) => candidate.slug === familyOf(matching, model.slug)); + if (entry?.status === "legacy") return model.isLegacy ? model : { ...model, isLegacy: true }; + if (!model.isLegacy) return model; + const { isLegacy: _isLegacy, ...rest } = model; + return rest; + }); +} + +/** + * Moves `isDefault` to the catalog's chat default when the catalog names one + * the models include. Providers that learn their default from the runtime can + * be overridden here without a release. Aliases that pointed at the old + * default move with the flag so the shared "provider default" alias keeps + * resolving. + */ +export function applyCatalogDefault( + models: ReadonlyArray, + catalog: ProviderCatalog | undefined, + matching?: CatalogMatching, +): ReadonlyArray { + const requestedSlug = catalog?.defaultChatModel; + if (requestedSlug === undefined) return models; + const slug = + models.find((model) => model.slug === requestedSlug)?.slug ?? + (matching?.family === undefined + ? undefined + : models.find( + (model) => + !model.isCustom && familyOf(matching, model.slug) === familyOf(matching, requestedSlug), + )?.slug); + if (slug === undefined) return models; + const previous = models.find((model) => model.isDefault && model.slug !== slug); + if (!previous) return models; + const movedAliases = previous.aliases ?? []; + return models.map((model) => { + if (model.slug === previous.slug) { + const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; + return rest; + } + if (model.slug === slug) { + const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; + return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; + } + return model; + }); +} + +/** + * Reclassifies a snapshot draft's built-in models and moves its default from + * the catalog. Drivers that announce models a newer CLI unlocks add + * `updateRequiredModels` themselves; this clears a stale list. + */ +export function applyModelCatalog( + draft: ServerProviderDraft, + catalog: ProviderCatalog | undefined, + matching?: CatalogMatching, +): ServerProviderDraft { + const { updateRequiredModels: _previous, ...rest } = draft; + return { + ...rest, + models: applyCatalogDefault( + classifyCatalogModels(draft.models, catalog, matching), + catalog, + matching, + ), + }; +} From b66dd402e86899c3fb4341d46c32d0d69df1eefc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 09:15:21 -0700 Subject: [PATCH 069/377] refactor(provider-core): follow the Effect service conventions throughout (#17427) Co-authored-by: Claude Opus 5.5 --- .../record-claude-agent-sdk-replay-fixture.ts | 9 +- .../scripts/record-grok-acp-replay-fixture.ts | 6 +- .../server/src/mcp/McpToolAccess.race.test.ts | 4 +- .../src/mcp/OrchestratorMcpService.test.ts | 6 +- ...OrchestratorMcpToolkit.integration.test.ts | 27 +- .../server/src/mcpApps/McpAppRequests.test.ts | 13 +- apps/server/src/mcpApps/McpAppRequests.ts | 11 +- .../AcpRegistryOrchestratorV2.live.test.ts | 2 +- .../Adapters/AcpAdapterV2.test.ts | 360 +++++++++--------- .../Adapters/AcpRegistryAdapterV2.test.ts | 8 +- .../Adapters/AntigravityAdapterV2.test.ts | 10 +- .../Adapters/ClaudeAdapterV2.test.ts | 168 ++++---- .../Adapters/ClaudeAdapterV2.ts | 18 +- .../Adapters/CodexAdapterV2.test.ts | 165 +++++--- .../Adapters/CodexAdapterV2.testkit.ts | 4 +- .../Adapters/CodexAdapterV2.ts | 208 +++++----- .../Adapters/CursorAdapterV2.testkit.ts | 4 +- .../Adapters/GrokAdapterV2.test.ts | 10 +- .../Adapters/GrokAdapterV2.testkit.ts | 2 +- .../Adapters/MuseAdapterV2.testkit.ts | 2 +- .../Adapters/OpenCode2AdapterV2.test.ts | 60 +-- .../Adapters/OpenCodeAdapterV2.testkit.ts | 2 +- .../BackgroundWorkStop.integration.test.ts | 15 +- .../CheckpointCaptureService.ts | 2 +- .../CheckpointRollbackService.test.ts | 4 +- .../CheckpointRollbackService.ts | 10 +- .../src/orchestration-v2/CheckpointService.ts | 4 +- .../ContextHandoffBudget.test.ts | 6 +- .../ContextHandoffDelivery.ts | 4 +- .../CursorOrchestratorV2.live.test.ts | 2 +- .../DelegatedCompletionDelivery.test.ts | 4 +- .../DispatchModeLimit.test.ts | 4 +- .../GrokOrchestratorV2.live.test.ts | 2 +- .../OpenCode2OrchestratorV2.live.test.ts | 4 +- .../Orchestrator.control-reads.test.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 14 +- .../ProviderAdapterRegistry.test.ts | 25 +- .../ProviderAdapterRegistry.ts | 16 +- .../ProviderContinuationService.test.ts | 2 +- .../ProviderContinuationService.ts | 2 +- .../orchestration-v2/ProviderEventIngestor.ts | 9 +- .../ProviderSessionManager.test.ts | 34 +- .../ProviderSessionManager.ts | 68 ++-- .../ProviderSwitchService.test.ts | 8 +- .../ProviderTurnControlService.test.ts | 4 +- .../ProviderTurnStartService.test.ts | 6 +- .../ProviderTurnStartService.ts | 23 +- .../RunExecutionService.test.ts | 143 ++++--- .../orchestration-v2/RunExecutionService.ts | 31 +- .../src/orchestration-v2/RuntimePolicy.ts | 11 +- .../RuntimeRequestService.test.ts | 6 +- .../SelectionRestart.integration.test.ts | 23 +- .../SteeringCompletion.integration.test.ts | 21 +- .../src/orchestration-v2/ThreadDeletion.ts | 6 +- .../ThreadFork.execution.test.ts | 4 +- .../ThreadLaunchService.test.ts | 4 +- .../src/orchestration-v2/ThreadStop.test.ts | 4 +- .../ThreadTitleRegenerationService.test.ts | 4 +- .../assistantStreaming.test.ts | 8 +- .../orchestration-v2/assistantStreaming.ts | 7 +- .../LegacyV1Cutover.integration.test.ts | 14 +- .../src/orchestration-v2/runtimeLayer.test.ts | 11 +- .../src/orchestration-v2/runtimeLayer.ts | 2 +- .../testkit/ProviderReplayHarness.ts | 2 +- .../ProviderSwitch.integration.test.ts | 25 +- apps/server/src/provider/CodexChatGptAuth.ts | 4 +- .../Drivers/AntigravityDriver.test.ts | 2 +- .../src/provider/Drivers/AntigravityDriver.ts | 10 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../src/provider/Drivers/CodexDriver.test.ts | 6 +- .../src/provider/Drivers/CodexDriver.ts | 9 +- .../provider/Drivers/CodexManagedProvider.ts | 4 +- apps/server/src/provider/EventNdjsonLogger.ts | 24 +- .../src/provider/ProviderEventLoggers.ts | 71 ---- apps/server/src/provider/ProviderHostLive.ts | 8 +- .../provider/ProviderInstanceRegistry.test.ts | 2 +- .../ProviderInstanceRegistryHydration.ts | 6 +- ...viderOrchestrationAdapterInfrastructure.ts | 2 +- .../src/provider/ProviderRegistry.test.ts | 2 +- apps/server/src/server.ts | 36 +- .../provider-acp-registry/src/server/auth.ts | 2 +- .../src/server/driver.ts | 8 +- packages/provider-acp/src/server/adapter.ts | 9 +- packages/provider-core/package.json | 14 +- .../provider-core/src/server/IdAllocator.ts | 108 +++--- .../src/server/ProviderAdapter.ts | 35 +- ...sts.ts => ProviderContinuationRequests.ts} | 2 +- .../provider-core/src/server/ProviderHost.ts | 59 ++- .../provider-core/src/server/adapterDriver.ts | 2 +- packages/provider-core/src/server/driver.ts | 2 +- packages/provider-core/src/server/failure.ts | 8 +- ...hFlow.test.ts => providerAuthFlow.test.ts} | 2 +- ...roviderAuthFlow.ts => providerAuthFlow.ts} | 0 .../src/server/snapshotSettings.ts | 34 +- .../provider-cursor/src/server/adapter.ts | 2 +- packages/provider-cursor/src/server/driver.ts | 2 +- packages/provider-grok/src/server/adapter.ts | 2 +- packages/provider-grok/src/server/driver.ts | 2 +- .../provider-muse/src/server/adapter.test.ts | 4 +- packages/provider-muse/src/server/adapter.ts | 2 +- packages/provider-muse/src/server/driver.ts | 4 +- .../provider-opencode/src/server/adapter.ts | 4 +- .../provider-opencode/src/server/driver.ts | 8 +- .../src/server/v2/adapter.ts | 4 +- .../provider-pi/src/server/adapter.test.ts | 2 +- packages/provider-pi/src/server/adapter.ts | 3 +- packages/provider-pi/src/server/driver.ts | 2 +- 107 files changed, 1123 insertions(+), 1099 deletions(-) delete mode 100644 apps/server/src/provider/ProviderEventLoggers.ts rename packages/provider-core/src/server/{continuationRequests.ts => ProviderContinuationRequests.ts} (97%) rename packages/provider-core/src/server/{ProviderAuthFlow.test.ts => providerAuthFlow.test.ts} (99%) rename packages/provider-core/src/server/{ProviderAuthFlow.ts => providerAuthFlow.ts} (100%) diff --git a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts index 970c1faf1e14..a509ed8c88de 100644 --- a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts +++ b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts @@ -10,10 +10,7 @@ import { CLAUDE_AGENT_SDK_REPLAY_PROTOCOL, } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts"; import { claudeRuntimeQueryPolicyForRuntimePolicy } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../src/orchestration-v2/RuntimePolicy.ts"; import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; @@ -440,8 +437,8 @@ validateClaudeReplayRecordingSelection({ function runtimePolicyForRecording(input: { readonly cwd: string; readonly override?: RuntimePolicyV2Override; -}): ProviderAdapterV2RuntimePolicyType { - return ProviderAdapterV2RuntimePolicy.make({ +}): ProviderAdapter.ProviderAdapterV2RuntimePolicy { + return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: input.override?.cwd ?? input.cwd, diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index fbf381f50537..da8b17fec567 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -33,8 +33,8 @@ import { } from "@t3tools/provider-grok/testing"; import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; @@ -474,7 +474,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s ...adapter, openSession: (input) => adapter.openSession(input).pipe( - Effect.map((session): ProviderAdapterV2SessionRuntime => ({ + Effect.map((session): ProviderAdapter.ProviderAdapterV2SessionRuntime => ({ ...session, startTurn: (turnInput) => onWallClock(session.startTurn(turnInput)), steerTurn: (turnInput) => onWallClock(session.steerTurn(turnInput)), diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 52458c8401e8..46522810f707 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -19,13 +19,13 @@ import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; // A Supervised outside agent renames a thread through a `writesThreads` tool. // The thread's user raises it to full access after the tool's check but @@ -38,7 +38,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("No provider process needed for metadata commands"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const layerDatabase = SqlitePersistence.layerMemory; const layerOrchestrator = Layer.mergeAll( layerDatabase, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eaa776173af3..2b2033779f21 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -28,7 +28,6 @@ import { OrchestratorProjectionError, OrchestratorThreadAboveModeLimitError, } from "../orchestration-v2/Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; @@ -39,6 +38,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; describe("OrchestratorMcpService", () => { it.effect("retries terminal acknowledgement with a fresh command id", () => @@ -810,7 +810,9 @@ describe("OrchestratorMcpService provider resolution", () => { list: () => Effect.succeed(instanceIds), get: (instanceId) => instanceIds.includes(instanceId) - ? Effect.succeed({ instanceId } as unknown as ProviderAdapterV2Shape) + ? Effect.succeed({ + instanceId, + } as unknown as ProviderAdapter.ProviderAdapterV2["Service"]) : Effect.fail( new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }), ), diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index f4aa8bafbe66..02c780b35946 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -57,14 +57,8 @@ import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.t import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import { - type ProviderAdapterV2Event, - ProviderAdapterProtocolError, - type ProviderAdapterV2Shape, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { @@ -79,6 +73,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; // Effect returns a declared tool failure as `isError` with its encoded payload // as JSON text, never as `structuredContent`. @@ -146,7 +141,7 @@ interface CapturedTurn { } function unsupported(driver: ProviderDriverKind, detail: string) { - return Effect.fail(new ProviderAdapterProtocolError({ driver, detail })); + return Effect.fail(new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail })); } function makeProviderSnapshot(input: { @@ -185,10 +180,12 @@ function makeDeterministicAdapter(input: { readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; readonly capturedTurns: Ref.Ref>; - readonly shouldComplete: (turn: ProviderAdapterV2TurnInput) => boolean; - readonly terminalGate?: (turn: ProviderAdapterV2TurnInput) => Deferred.Deferred | undefined; - readonly response: (turn: ProviderAdapterV2TurnInput) => string; -}): ProviderAdapterV2Shape { + readonly shouldComplete: (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => boolean; + readonly terminalGate?: ( + turn: ProviderAdapter.ProviderAdapterV2TurnInput, + ) => Deferred.Deferred | undefined; + readonly response: (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => string; +}): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: input.instanceId, driver: input.driver, @@ -196,7 +193,7 @@ function makeDeterministicAdapter(input: { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (sessionInput) => Effect.gen(function* () { - const events = yield* PubSub.unbounded(); + const events = yield* PubSub.unbounded(); const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -211,12 +208,12 @@ function makeDeterministicAdapter(input: { lastError: null, }; - const publish = (providerEvents: ReadonlyArray) => + const publish = (providerEvents: ReadonlyArray) => Effect.forEach(providerEvents, (event) => PubSub.publish(events, event), { discard: true, }); const runOrdinals = new Map(); - const turnInputs = new Map(); + const turnInputs = new Map(); return { instanceId: input.instanceId, diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index ce2d6428091c..dfd01ad27daa 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -14,10 +14,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import type { - ProviderAdapterV2McpApps, - ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; @@ -71,7 +68,7 @@ const storedContext = new Map(); function makeLayer(input: { readonly item: OrchestrationV2TurnItem | null; - readonly mcpApps?: ProviderAdapterV2McpApps; + readonly mcpApps?: ProviderAdapter.ProviderAdapterV2McpApps; readonly live?: boolean; }) { return McpAppRequests.layer.pipe( @@ -121,7 +118,9 @@ function makeLayer(input: { : Option.some( (input.mcpApps === undefined ? {} - : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime, + : { + mcpApps: input.mcpApps, + }) as ProviderAdapter.ProviderAdapterV2SessionRuntime, ), ), }), @@ -138,7 +137,7 @@ const reason = (effect: Effect.Effect) => describe("McpAppRequests", () => { const calls: Array<{ server: string; tool: string }> = []; - const mcpApps: ProviderAdapterV2McpApps = { + const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { listTools: () => Effect.succeed([ { name: "refresh", annotations: { readOnlyHint: true } }, diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 41902c528aeb..ee8f6d521324 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -21,10 +21,7 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import type { - ProviderAdapterV2McpApps, - ProviderAdapterV2McpTool, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; @@ -57,10 +54,10 @@ export class McpAppRequests extends Context.Service< const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const utf8 = new TextEncoder(); -const readOnlyHint = (tool: ProviderAdapterV2McpTool) => +const readOnlyHint = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; -const toolTitle = (tool: ProviderAdapterV2McpTool) => +const toolTitle = (tool: ProviderAdapter.ProviderAdapterV2McpTool) => Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" ? tool.annotations.title : undefined; @@ -112,7 +109,7 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), ); if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); - const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps; + const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps | undefined = runtime.mcpApps; if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); return { app, providerThread, mcpApps }; }); diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 4f2ea3e2f3d6..f39c1c211532 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -29,7 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index bda7b91e735a..f1f33d43233f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -64,13 +64,8 @@ import { registerXAiBackgroundTaskTracking, } from "@t3tools/provider-grok/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterProtocolError, - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { AcpProviderCapabilitiesV2, acpProviderItemNativeId, @@ -540,7 +535,7 @@ function makeTurnInput(input: { readonly threadId: ThreadId; readonly providerThread: OrchestrationV2ProviderThread; readonly instanceId: ProviderInstanceId; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly now: DateTime.Utc; readonly ordinal?: number; readonly modelSelection?: ModelSelection; @@ -548,7 +543,7 @@ function makeTurnInput(input: { readonly messageCreatedBy?: "user" | "agent"; readonly messageCreationSource?: "web" | "mobile" | "mcp" | "provider" | "server"; readonly messageText?: string; -}): ProviderAdapterV2TurnInput { +}): ProviderAdapter.ProviderAdapterV2TurnInput { const ordinal = input.ordinal ?? 1; const suffix = `${input.threadId}:${ordinal}`; const modelSelection = @@ -625,7 +620,7 @@ describe("AcpAdapterV2", () => { }), }, }); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -742,7 +737,7 @@ describe("AcpAdapterV2", () => { }, selfInvocation, }); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -799,7 +794,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const policy = (interactionMode: "default" | "plan") => - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode, cwd: process.cwd(), @@ -820,7 +815,7 @@ describe("AcpAdapterV2", () => { const now = yield* DateTime.now; const runTurn = Effect.fnUntraced(function* ( ordinal: number, - runtimePolicy: ProviderAdapterV2RuntimePolicy, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, messageText: string, ) { yield* runtime.startTurn( @@ -917,7 +912,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-plan-replay-boundary"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1091,7 +1086,7 @@ describe("AcpAdapterV2", () => { }); const threadId = ThreadId.make("devin-streamed-write"); const modelSelection = { instanceId, model: "default" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1342,7 +1337,7 @@ describe("AcpAdapterV2", () => { }); const threadId = ThreadId.make("devin-replay-parent"); const modelSelection = { instanceId, model: "default" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1504,7 +1499,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-v2-fidelity"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1684,7 +1679,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-eager-resume"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1765,7 +1760,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-eager-resume"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1910,7 +1905,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-unexpected-termination"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -1993,7 +1988,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2065,7 +2060,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2130,7 +2125,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2226,7 +2221,7 @@ describe("AcpAdapterV2", () => { McpProviderSession.clearMcpProviderSession(targetThreadId); }), ); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2392,7 +2387,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-no-client-fs"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: workspace, @@ -2493,7 +2488,7 @@ describe("AcpAdapterV2", () => { clientTerminals: { childProcessSpawner }, }); const threadId = ThreadId.make("thread-acp-unknown-permission-grant"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -2598,7 +2593,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-native-thread"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2629,7 +2624,7 @@ describe("AcpAdapterV2", () => { .pipe(Effect.flip); assert.equal(error._tag, "ProviderAdapterTurnStartError"); - assert.instanceOf(error.cause, ProviderAdapterProtocolError); + assert.instanceOf(error.cause, ProviderAdapter.ProviderAdapterProtocolError); assert.include(String(error.cause), "missing its ACP session id"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); @@ -2676,7 +2671,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-session"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2689,7 +2684,7 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); assert.isTrue(runtime.providerSession.capabilities.threads.canRollbackThread); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -2883,7 +2878,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-rollback-retry-generation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -2899,7 +2894,7 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("ACP runtime must expose background work state"); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -2995,7 +2990,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3009,7 +3004,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -3079,7 +3074,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-idle-finalizer"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3129,7 +3124,7 @@ describe("AcpAdapterV2", () => { threadId: ThreadId.make(`grok-model-${model}`), providerSessionId: ProviderSessionId.make(`grok-model-${model}`), modelSelection: { instanceId, model }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3173,7 +3168,7 @@ describe("AcpAdapterV2", () => { ), }); const threadId = ThreadId.make("grok-model-switch-back"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3242,7 +3237,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsupported-option"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3286,7 +3281,7 @@ describe("AcpAdapterV2", () => { }); const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); const secondThreadId = ThreadId.make("thread-acp-active-setup:second"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3400,7 +3395,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-empty-successful-bash"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3412,7 +3407,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -3540,7 +3535,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-native-cancel"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3642,7 +3637,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-permission"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -3741,7 +3736,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-response-wins-permission"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -3833,7 +3828,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-reordered-elicitation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -3908,7 +3903,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-mcp-approval-elicitation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -3983,7 +3978,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-normal-close-held-response"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -4089,7 +4084,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-close-wins-registration"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -4208,7 +4203,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-pending-response-timeout"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -4333,7 +4328,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-pending-response-cancel"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -4442,7 +4437,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", approvalPolicy: "never", @@ -4535,7 +4530,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -4626,7 +4621,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-response-ack"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", approvalPolicy: "never", @@ -4708,7 +4703,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -4802,7 +4797,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-cancel-timeout"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -4903,7 +4898,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-double-stop"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -4915,7 +4910,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5007,7 +5002,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5019,7 +5014,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5111,7 +5106,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-subagent-carryover"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5123,7 +5118,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5252,7 +5247,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-carryover-pending-pin"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5270,7 +5265,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5416,7 +5411,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-active-carryover-pending-pin"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5436,7 +5431,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5661,7 +5656,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-subagent-finalize-window"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5681,7 +5676,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -5796,7 +5791,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const promptSettled = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-post-settle"; @@ -5859,7 +5855,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-child-session-post-settle"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -5879,7 +5875,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -6046,7 +6042,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-carryover-subagent-finished"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -6062,7 +6058,7 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("post-settle continuation must expose hasPendingBackgroundWork"); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -6151,7 +6147,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd"; let subagentPhase: "spawn" | "complete" = "spawn"; @@ -6210,7 +6207,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-completed-root-eager-carryover"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -6230,7 +6227,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -6396,7 +6393,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); let subagentPhase: "spawn" | "complete" = "spawn"; @@ -6458,7 +6456,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-root-session-continuation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -6478,7 +6476,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -6678,7 +6676,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); @@ -6750,7 +6749,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-already-handled-re-report"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -6770,7 +6769,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -6914,7 +6913,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const promptSettled = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "019f5470-bf92-7a90-afb3-5a6cea5b34a3"; @@ -6988,7 +6988,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-root-end-notice"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7006,7 +7006,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -7136,7 +7136,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-pending-continuation"; const bufferedAssistantText = "POST_SETTLE_BUFFERED_ASSISTANT_TEXT"; @@ -7199,7 +7200,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-child-pending-continuation"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7219,7 +7220,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -7401,7 +7402,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const instanceId = ProviderInstanceId.make("acp-test"); const childSessionId = "mock-child-session-root-then-child"; let subagentPhase: "spawn" | "complete" = "spawn"; @@ -7463,7 +7465,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-carryover-root-then-child"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7483,7 +7485,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -7701,7 +7703,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-steer"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7713,7 +7715,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -7834,7 +7836,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7892,7 +7894,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const instanceId = ProviderInstanceId.make("acp-test"); let cancelCalled = false; let runtimeOrdinalSeen = 0; @@ -7940,7 +7943,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -7958,7 +7961,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8115,7 +8118,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -8133,7 +8136,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8255,7 +8258,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-production-stop-hard-kill"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -8267,7 +8270,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8417,7 +8420,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -8431,7 +8434,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8700,7 +8703,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-direct-stop-deferred-terminal"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -8720,7 +8723,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8904,7 +8907,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -8918,7 +8921,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -8999,7 +9002,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, @@ -9037,7 +9041,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-already-handled-wake-pin"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9057,7 +9061,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9123,7 +9127,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test"); @@ -9154,7 +9159,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-sticky-continuation-dispatch"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9168,7 +9173,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9237,7 +9242,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const userPromptStarted = yield* Deferred.make(); const releaseUserPrompt = yield* Deferred.make(); const bufferedAssistantText = "BUFFERED_WAKE_QUEUED_AFTER_USER"; @@ -9284,7 +9290,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-buffered-continuation-user-race"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9304,7 +9310,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9480,7 +9486,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-injected-report-hold"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9492,7 +9498,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9515,7 +9521,7 @@ describe("AcpAdapterV2", () => { // hydration are ingested: the hydrated monitor card carries the // fetched listing. let reportSeen = false; - const trackReport = (event: ProviderAdapterV2Event): void => { + const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { if ( event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" && @@ -9605,7 +9611,8 @@ describe("AcpAdapterV2", () => { const triggerDir = yield* fileSystem.makeTempDirectoryScoped(); const triggerPath = path.join(triggerDir, "report-trigger"); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const capturedMutation: { current: | ((mutation: { @@ -9662,7 +9669,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-settle-hold-ext-complete-no-wake"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9676,7 +9683,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9696,7 +9703,7 @@ describe("AcpAdapterV2", () => { }); let reportSeen = false; - const trackReport = (event: ProviderAdapterV2Event): void => { + const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { if ( event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" && @@ -9799,7 +9806,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -9854,7 +9862,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-pre-settle-arm-cleared-by-report"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -9868,7 +9876,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -9965,7 +9973,7 @@ describe("AcpAdapterV2", () => { yield* Effect.yieldNow; let reportSeen = false; - const trackReport = (event: ProviderAdapterV2Event): void => { + const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { if ( event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" && @@ -10033,7 +10041,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -10095,7 +10104,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-staggered-midturn-keep-until-last"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -10109,7 +10118,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -10246,7 +10255,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -10312,7 +10322,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-staggered-midturn-clear-on-interrupt"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -10326,7 +10336,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -10455,7 +10465,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGates: Array> = []; @@ -10543,7 +10554,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-multiturn-in-turn-monitor-no-wake"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -10563,7 +10574,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -10820,7 +10831,8 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; let applyMutation: AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined; @@ -10860,7 +10872,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-wake-report-while-queued"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -10872,7 +10884,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -10969,7 +10981,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGates: Array> = []; @@ -11038,7 +11051,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-mid-turn-dirty-wake-buffer"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -11058,7 +11071,7 @@ describe("AcpAdapterV2", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -11294,7 +11307,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -11344,7 +11358,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-mid-turn-unhandled-offers-after-finalize"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -11358,7 +11372,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -11446,7 +11460,7 @@ describe("AcpAdapterV2", () => { content: { type: "text", text: " \n\t " }, }, }); - const lateEvents: Array = []; + const lateEvents: Array = []; let lateEvent = yield* Queue.poll(events); while (Option.isSome(lateEvent)) { lateEvents.push(lateEvent.value); @@ -11475,7 +11489,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -11529,7 +11544,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-empty-drain-continuation-quiet"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -11543,7 +11558,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -11713,7 +11728,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; const promptGate = yield* Deferred.make(); @@ -11765,7 +11781,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-empty-drain-continuation-no-wedge"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -11779,7 +11795,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -11913,7 +11929,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -12144,7 +12160,8 @@ describe("AcpAdapterV2", () => { const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log"); const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger"); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const teardownStarted = yield* Deferred.make(); const releaseTeardown = yield* Deferred.make(); let terminatorCallCount = 0; @@ -12187,7 +12204,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-failed-hard-teardown"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -12202,7 +12219,7 @@ describe("AcpAdapterV2", () => { runtimePolicy, }) .pipe(Effect.provideService(Scope.Scope, sessionScope)); - const adapterEvents = yield* Queue.unbounded(); + const adapterEvents = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(adapterEvents, event)), Effect.forkIn(sessionScope), @@ -12368,7 +12385,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -12464,7 +12481,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -12540,7 +12557,8 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const responseLifecycle: Array = []; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; type HandlerRecord = { @@ -12633,7 +12651,7 @@ describe("AcpAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -12647,7 +12665,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const adapterEvents = yield* Queue.unbounded(); + const adapterEvents = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(adapterEvents, event)), Effect.forkScoped, @@ -12849,7 +12867,7 @@ describe("AcpAdapterV2", () => { method: "_x.ai/ask_user_question", }, ).pipe(Effect.forkScoped); - let replacementRequest: ProviderAdapterV2Event | undefined; + let replacementRequest: ProviderAdapter.ProviderAdapterV2Event | undefined; while (replacementRequest === undefined) { const event = yield* Queue.take(adapterEvents); if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") { @@ -12916,7 +12934,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: process.cwd(), @@ -12928,7 +12946,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -12975,7 +12993,7 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); - let replacementPending: ProviderAdapterV2Event | undefined; + let replacementPending: ProviderAdapter.ProviderAdapterV2Event | undefined; while (replacementPending === undefined) { const event = yield* Queue.take(events); if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") { @@ -13045,7 +13063,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -13192,7 +13210,7 @@ describe("AcpAdapterV2", () => { continuationRequests: { offer: () => Effect.void }, }); const threadId = ThreadId.make("thread-acp-direct-stop-running-command"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -13206,7 +13224,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -13458,7 +13476,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-direct-stop-subagent-hold"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -13472,7 +13490,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, @@ -13602,7 +13620,7 @@ describe("AcpAdapterV2", () => { selfInvocation, }); const threadId = ThreadId.make("thread-acp-restart-active-in-process"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -13614,7 +13632,7 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), Effect.forkScoped, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 728e8bc4c91d..c640ae354919 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -29,7 +29,7 @@ import { makeAcpReplayCompletenessAssertion, makeAcpReplayRuntime, } from "./AcpAdapterV2.testkit.ts"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server"; import { @@ -216,7 +216,7 @@ describe("AcpRegistryAdapterV2", () => { model: "default", options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: input.storedModePick }], }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: replayDir, @@ -304,7 +304,7 @@ describe("AcpRegistryAdapterV2", () => { ); }), }); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), @@ -413,7 +413,7 @@ describe("AcpRegistryAdapterV2", () => { }, }); const threadId = ThreadId.make("thread-acp-registry-fixture"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index f997751f2fdd..9579da1fa91c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -27,7 +27,7 @@ import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { makeAntigravityAcpAdapterFlavor, makeAntigravityAdapterV2, @@ -74,7 +74,7 @@ describe("AntigravityAdapterV2 flavor", () => { it("maps runtime modes to the agent's native permission modes", () => { const mode = (runtimeMode: "approval-required" | "auto-accept-edits" | "full-access") => flavor.sessionModeForPolicy?.( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode, interactionMode: "default", cwd: "/workspace", @@ -190,7 +190,7 @@ describe("AntigravityAdapterV2 client file system", () => { yield* fileSystem.writeFileString(attachment, "pasted"); const threadId = ThreadId.make("thread-antigravity-containment"); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: workspace, @@ -325,7 +325,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { yield* fileSystem.writeFileString(path.join(workspaceA, "a.txt"), "from a"); yield* fileSystem.writeFileString(path.join(workspaceB, "b.txt"), "from b"); const policyFor = (cwd: string) => - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd, @@ -476,7 +476,7 @@ describe("AntigravityAdapterV2 client file system under restrictive policies", ( }); yield* fileSystem.writeFileString(path.join(workspace, "existing.ts"), "existing"); const threadId = ThreadId.make(`thread-antigravity-restrictive-${policy.runtimeMode}`); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ ...policy, interactionMode: "default", cwd: workspace, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bfef7f9e9a10..eed58e1aa416 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -61,13 +61,9 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -81,7 +77,7 @@ const CLAUDE_TEST_MODEL_SELECTION = { model: "claude-sonnet-4-6", options: [{ id: "effort", value: "ultrathink" }], } satisfies ModelSelection; -const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapterV2RuntimePolicy.make({ +const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -127,14 +123,14 @@ function makeClaudeTestTurnInput(input: { readonly now: DateTime.Utc; readonly attemptId: RunAttemptId; readonly text: string; - readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"]; + readonly attachments: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["attachments"]; readonly providerTurnOrdinal?: number; readonly nativeThreadHasTurns?: boolean; - readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"]; - readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"]; + readonly messageCreatedBy?: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["createdBy"]; + readonly messageCreationSource?: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["creationSource"]; readonly modelSelection?: ModelSelection; - readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; -}): ProviderAdapterV2TurnInput { + readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; +}): ProviderAdapter.ProviderAdapterV2TurnInput { return { appThread: makeClaudeTestAppThread(input), threadId: input.threadId, @@ -298,7 +294,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("maps canonical read-only never policy to Claude dontAsk with read-only tools", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -321,7 +317,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("maps canonical read-only on-request policy to Claude default with callbacks", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -344,7 +340,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("does not auto-allow reads for canonical restricted read-only never policy", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -370,7 +366,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("maps default full-access policy to Claude bypass permissions", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -386,7 +382,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("maps Auto runtime mode to Claude's AI-reviewed permission mode", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "auto", interactionMode: "default", cwd: "/workspace", @@ -401,7 +397,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("keeps approval-required mode interactive with danger-full-access sandboxing", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: "/workspace", @@ -419,7 +415,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("installs the permission callback for approval-required plan mode", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "plan", cwd: "/workspace", @@ -434,7 +430,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("honors never approvals for approval-required workspace-write policy", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: "/workspace", @@ -453,7 +449,7 @@ describe("ClaudeAdapterV2 runtime query policy", () => { it("honors never approvals for externally sandboxed policy", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: "/workspace", @@ -595,7 +591,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { const threadId = ThreadId.make("thread-claude-mcp-query-key"); withMcpSession(threadId, () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -627,7 +623,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { const threadId = ThreadId.make("thread-claude-mcp-credential-rotation"); withMcpSession(threadId, () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: "/workspace", @@ -750,7 +746,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { readonly event: unknown; readonly threadId: ThreadId | null; }> = []; - const logger: EventNdjsonLogger = { + const logger: ProviderEventLoggers.EventNdjsonLogger = { filePath: "/tmp/events.log", write: (event, threadId) => Effect.sync(() => { @@ -993,7 +989,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assertComplete: Effect.void, }, }); - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "auto-accept-edits", interactionMode: "default", cwd: "/workspace", @@ -1701,7 +1697,7 @@ describe("ClaudeAdapterV2 native fork", () => { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -1713,7 +1709,7 @@ describe("ClaudeAdapterV2 native fork", () => { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -1808,7 +1804,7 @@ describe("ClaudeAdapterV2 native fork", () => { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -2120,11 +2116,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const offeredMessages: Array = []; const permissionModeChanges: Array = []; - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const terminalReceipts = - yield* Queue.unbounded>(); + yield* Queue.unbounded< + Extract + >(); const systemNoticeReceipts = - yield* Queue.unbounded>(); + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2210,7 +2211,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); - const events: Array = []; + const events: Array = []; yield* runtime.events.pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -2231,7 +2232,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; const terminalEvents = () => events.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ); return { @@ -2817,7 +2820,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attemptId: RunAttemptId.make("attempt-claude-auth-failure"), text: "Continue.", attachments: [], - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd, @@ -3633,10 +3636,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - const providerThreadRosterEvents = (events: ReadonlyArray) => + const providerThreadRosterEvents = ( + events: ReadonlyArray, + ) => events.filter( - (event): event is Extract => - event.type === "provider_thread.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_thread.updated" } + > => event.type === "provider_thread.updated", ); it.effect( @@ -3932,8 +3941,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const processQueues: Array> = []; const firstCloseRequested = yield* Deferred.make(); - const events: Array = []; - const continuationRequests: Array = []; + const events: Array = []; + const continuationRequests: Array = + []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -4179,7 +4189,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly nativeThreadId: string; readonly queue: Queue.Queue; }> = []; - const events: Array = []; + const events: Array = []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -4430,7 +4440,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode, interactionMode: "default", cwd: "/workspace", @@ -4731,7 +4741,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const approvalPolicy = ProviderAdapterV2RuntimePolicy.make({ + const approvalPolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", interactionMode: "default", cwd: "/workspace", @@ -5972,7 +5982,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }); const subagentRouting = ( - events: ReadonlyArray, + events: ReadonlyArray, nativeToolIds: ReadonlyArray, ) => { const childThreadId = @@ -6090,7 +6100,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const userTurn = ( attempt: string, providerTurnOrdinal: number, - attachments: ProviderAdapterV2TurnInput["message"]["attachments"] = [], + attachments: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["attachments"] = [], ) => harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -7838,8 +7848,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const subagentEvents = () => harness.events.filter( - (event): event is Extract => - event.type === "subagent.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); yield* harness.runtime.startTurn( @@ -7942,8 +7956,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const subagentEvents = () => harness.events.filter( - (event): event is Extract => - event.type === "subagent.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); yield* harness.runtime.startTurn( @@ -8059,8 +8077,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const subagentEvents = () => harness.events.filter( - (event): event is Extract => - event.type === "subagent.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); yield* harness.runtime.startTurn( @@ -8249,7 +8271,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { // already pre-opened by the wake buffer before the drain replay. const nodeStatuses = harness.events .filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "node.updated" && event.node.kind === "subagent" && event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, @@ -8288,8 +8312,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-process-reset-", }); const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; + const events: Array = []; + const continuationRequests: Array = + []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -8472,8 +8497,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-buffer-replace-", }); const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; + const events: Array = []; + const continuationRequests: Array = + []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -8708,8 +8734,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-subagent-buffer-replace-", }); const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; + const events: Array = []; + const continuationRequests: Array = + []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -8775,8 +8802,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; const subagentEvents = () => events.filter( - (event): event is Extract => - event.type === "subagent.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); const now = yield* DateTime.now; @@ -8880,7 +8911,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY); assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId); const subagentNodeEvents = events.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "node.updated" && event.node.kind === "subagent" && event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, @@ -8902,7 +8935,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-model-change-running-subagent-", }); const processQueues: Array> = []; - const events: Array = []; + const events: Array = []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -9069,7 +9102,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); let openCount = 0; const processQueues: Array> = []; - const events: Array = []; + const events: Array = []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -9198,8 +9231,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); let openCount = 0; const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = []; + const events: Array = []; + const continuationRequests: Array = + []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -9394,7 +9428,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-v2-first-open-fail-", }); - const events: Array = []; + const events: Array = []; const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -9496,7 +9530,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid, session_id: WAKE_NATIVE_SESSION, }); - const goalStatuses = (events: ReadonlyArray) => + const goalStatuses = (events: ReadonlyArray) => events.flatMap((event) => event.type === "provider_thread.updated" && event.providerThread.goal != null ? [event.providerThread.goal] diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index de747802d349..0eb2dff9bfe3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -101,10 +101,9 @@ import { } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, - type EventNdjsonLogger, shouldPersistProviderEvent, } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { claudeRateLimitEventToUpdate, type ClaudeScopedLimitNames, @@ -135,7 +134,7 @@ import { type BackgroundWorkReport, backgroundWorkNotification, } from "@t3tools/provider-core/server/notification"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, @@ -570,7 +569,7 @@ export function loggedClaudeQueryOptions( } export function makeClaudeAgentSdkProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; }): ClaudeAgentSdkProtocolLogger | undefined { @@ -1118,7 +1117,7 @@ function resultTextFromSdkMessage( } function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly providerInstanceId: ProviderInstanceId; readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; @@ -2581,7 +2580,7 @@ function providerFailureFromApiRetry(message: SDKAPIRetryMessage): Orchestration } function buildAssistantArtifacts(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly nativeItemId: string; @@ -3015,7 +3014,7 @@ export interface ClaudeAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly crypto: Crypto.Crypto; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ServerProviderShape["applyUsageLimits"]; @@ -3029,7 +3028,7 @@ export interface ClaudeAdapterV2Options { export function makeClaudeAdapterV2( adapterOptions: ClaudeAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { +): ProviderAdapter.ProviderAdapterV2["Service"] { const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, @@ -3356,7 +3355,6 @@ export function makeClaudeAdapterV2( type: "turn_item.updated", driver: CLAUDE_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CLAUDE_PROVIDER, threadId: context.input.threadId, runId: context.input.runId, @@ -5083,7 +5081,6 @@ export function makeClaudeAdapterV2( type: "turn_item.updated", driver: CLAUDE_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CLAUDE_PROVIDER, threadId: input.context.input.threadId, runId: input.context.input.runId, @@ -6089,7 +6086,6 @@ export function makeClaudeAdapterV2( type: "turn_item.updated", driver: CLAUDE_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CLAUDE_PROVIDER, threadId: context.input.threadId, runId: context.input.runId, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 4188c3248a5f..ef884b9c3abc 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,7 +1,6 @@ import * as NodeOS from "node:os"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; -import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderTextDeltaCoalescer, type ProviderTextDeltaUpdate, @@ -56,21 +55,13 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import packageJson from "../../../package.json" with { type: "json" }; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as EffectWorker from "../EffectWorker.ts"; import * as Orchestrator from "../Orchestrator.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; -import { - ProviderAdapterForkThreadError, - ProviderAdapterOpenSessionError, - ProviderAdapterRollbackThreadError, - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; @@ -767,7 +758,7 @@ describe("CodexAdapterV2 process spawning", () => { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, threadId: ThreadId.make("thread-launch-args"), providerSessionId: ProviderSessionId.make("provider-session-launch-args"), - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -1036,7 +1027,7 @@ describe("CodexAdapterV2 native protocol logging", () => { readonly event: unknown; readonly threadId: ThreadId | null; }> = []; - const logger: EventNdjsonLogger = { + const logger: ProviderEventLoggers.EventNdjsonLogger = { filePath: "/tmp/events.log", write: (event, threadId) => Effect.sync(() => { @@ -1410,7 +1401,7 @@ describe("CodexAdapterV2 fork boundary", () => { }), ); - assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); assert.include(String(error.cause), "provider-turn-missing"); }), ); @@ -1478,7 +1469,7 @@ const CODEX_TEST_MODEL_SELECTION = { instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, model: "gpt-5.4", } satisfies ModelSelection; -const CODEX_TEST_RUNTIME_POLICY = ProviderAdapterV2RuntimePolicy.make({ +const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: "/workspace", @@ -1524,7 +1515,7 @@ function makeCodexTestTurnInput(input: { readonly now: DateTime.Utc; readonly attemptId: RunAttemptId; readonly text: string; -}): ProviderAdapterV2TurnInput { +}): ProviderAdapter.ProviderAdapterV2TurnInput { return { appThread: makeCodexTestAppThread(input), threadId: input.threadId, @@ -1747,7 +1738,7 @@ describe("CodexAdapterV2 session initialize", () => { ), Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CodexAdapterV2.CODEX_DRIVER_KIND, providerSessionId: openInput.providerSessionId, cause, @@ -1907,7 +1898,8 @@ describe("CodexAdapterV2 post-settle continuation", () => { const makeCodexReplayHarness = ( transcript: CodexReplay.CodexAppServerReplayTranscript, - onEvent: (event: ProviderAdapterV2Event) => Effect.Effect = () => Effect.void, + onEvent: (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.Effect = () => + Effect.void, onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, readChildMetadata?: Parameters[2], ) => @@ -1915,13 +1907,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); - const continuationRequests: Array = []; + const continuationRequests: Array = + []; const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { open: (openInput) => Layer.build(CodexReplay.layerReplay(transcript)).pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CodexAdapterV2.CODEX_DRIVER_KIND, providerSessionId: openInput.providerSessionId, cause, @@ -1975,7 +1968,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { modelSelection: CODEX_TEST_MODEL_SELECTION, runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); - const events: Array = []; + const events: Array = []; const firstTerminal = yield* Deferred.make(); yield* runtime.events.pipe( Stream.runForEach((event) => @@ -1998,13 +1991,19 @@ describe("CodexAdapterV2 post-settle continuation", () => { const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; const terminalEvents = () => events.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ); const subagentUpdates = () => events.filter( - (event): event is Extract => - event.type === "subagent.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); return { runtime, @@ -2026,7 +2025,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { Effect.gen(function* () { const nativeThreadId = `inject-${response}`; const prompt = "Only the current request"; - const history: ProviderAdapterV2HistoricalContext = { + const history: ProviderAdapter.ProviderAdapterV2HistoricalContext = { context: "Historical conversation", messages: (["user", "assistant"] as const).map((role) => ({ role, @@ -2528,12 +2527,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); - const assistantMessages = (events: ReadonlyArray) => + const assistantMessages = (events: ReadonlyArray) => events.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "message.updated" && event.message.role === "assistant", ); - const assistantTurnItems = (events: ReadonlyArray) => + const assistantTurnItems = (events: ReadonlyArray) => events.flatMap((event) => event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" ? [event.turnItem] @@ -4914,8 +4915,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated", )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -4931,7 +4936,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isAtLeast(terminalIndex, 0); let lastCommandBeforeTerminal: - | Extract + | Extract | undefined; for (let index = 0; index < terminalIndex; index++) { const event = harness.events[index]; @@ -4997,8 +5002,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); const commandUpdates = harness.events.filter( - (event): event is Extract => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", ); assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); @@ -5142,7 +5151,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); const assertChildProviderTerminalBeforeRoot = ( - events: ReadonlyArray, + events: ReadonlyArray, rootThreadId: ThreadId, ) => { const terminalIndex = events.findIndex((event) => event.type === "turn.terminal"); @@ -5199,8 +5208,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running child command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated" && event.threadId === harness.threadId, + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5212,7 +5225,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); const childCommandUpdates = harness.events.filter( - (event): event is Extract => + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, @@ -5289,8 +5307,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running child command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated" && event.threadId === harness.threadId, + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5361,8 +5383,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running child command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated" && event.threadId === harness.threadId, + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5460,8 +5486,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running child command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated" && event.threadId === harness.threadId, + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5481,7 +5511,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); const lateChildUpdates = harness.events.filter( - (event): event is Extract => + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, ); @@ -5589,8 +5624,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running child command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated" && event.threadId === harness.threadId, + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5835,8 +5874,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated", )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -5868,8 +5911,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(terminalProviderTurnsBeforeLateEvents, 1); const commandUpdatesBeforeLateEvents = harness.events.filter( - (event): event is Extract => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", ); const terminalCommands = commandUpdatesBeforeLateEvents.filter( (event) => @@ -5993,8 +6040,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { "running command item", ); const providerTurnId = harness.events.find( - (event): event is Extract => - event.type === "provider_turn.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated", )?.providerTurn.id; assert.isDefined(providerTurnId); @@ -7663,7 +7714,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); - assert.instanceOf(error, ProviderAdapterRollbackThreadError); + assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError); assert.include( errorCauseChainText(error), "legacy", @@ -7904,7 +7955,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); - assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); assert.include( errorCauseChainText(error), "legacy", @@ -7969,7 +8020,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); - assert.instanceOf(error, ProviderAdapterForkThreadError); + assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); assert.include(errorCauseChainText(error), "fork exploded"); }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); @@ -8033,7 +8084,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ), ); - const providerGoals = (events: ReadonlyArray) => + const providerGoals = (events: ReadonlyArray) => events.flatMap((event) => event.type === "provider_thread.updated" ? [event.providerThread.goal?.status ?? null] : [], ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 0941a1f88e3a..ea949828076d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../../config.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterOpenSessionError } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; @@ -195,7 +195,7 @@ export function layer(input: { const context = yield* Layer.build(layerReplay).pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CodexAdapterV2.CODEX_DRIVER_KIND, providerSessionId: openInput.providerSessionId, cause, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index c8b988289390..6ae159b2cc33 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -102,10 +102,9 @@ import { } from "../../provider/Drivers/CodexHomeLayout.ts"; import { boundProviderEventForLogging, - type EventNdjsonLogger, shouldPersistProviderEvent, } from "../../provider/EventNdjsonLogger.ts"; -import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; @@ -121,11 +120,7 @@ import { type ProviderAdapterDriver, type ProviderAdapterDriverCreateInput, } from "@t3tools/provider-core/server/adapterDriver"; -import { IdAllocatorV2, type IdAllocatorV2Shape } from "@t3tools/provider-core/server/IdAllocator"; -import { - type ProviderContinuationRequest, - ProviderContinuationRequests, -} from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { backgroundWorkNotification } from "@t3tools/provider-core/server/notification"; import { makeProviderFailure, @@ -137,35 +132,13 @@ import { isProviderNativeImageAttachment, providerMessageTextWithAttachmentPaths, } from "@t3tools/provider-core/server/attachmentPrompt"; -import { - ProviderAdapterEnsureThreadError, - ProviderAdapterForkThreadError, - ProviderAdapterInterruptError, - ProviderAdapterOpenSessionError, - ProviderAdapterProtocolError, - ProviderAdapterReadThreadSnapshotError, - ProviderAdapterResumeThreadError, - ProviderAdapterRollbackThreadError, - ProviderAdapterRuntimeRequestResponseError, - ProviderAdapterSteerRunError, - ProviderAdapterTurnStartError, - ProviderAdapterV2, - type ProviderAdapterV2Shape, - type ProviderAdapterV2Event, - type ProviderAdapterV2ForkThreadInput, - type ProviderAdapterV2RollbackThreadInput, - type ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2McpApps, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2InterruptInput, - type ProviderAdapterV2SteerInput, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; import { makeSubagentChildThread, makeSubagentConversationArtifacts, subagentThreadTitle, } from "@t3tools/provider-core/server/subagentProjection"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const CODEX_PROVIDER = ProviderDriverKind.make("codex"); export const CODEX_DRIVER_KIND = CODEX_PROVIDER; @@ -341,8 +314,11 @@ export const CodexProviderCapabilitiesV2 = { }, } satisfies OrchestrationV2ProviderCapabilities; -function toProtocolError(detail: string, payload?: unknown): ProviderAdapterProtocolError { - return new ProviderAdapterProtocolError({ +function toProtocolError( + detail: string, + payload?: unknown, +): ProviderAdapter.ProviderAdapterProtocolError { + return new ProviderAdapter.ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail, ...(payload === undefined ? {} : { payload }), @@ -385,7 +361,7 @@ function mapCodexTurnStatus( function providerTurnStatusToTerminal( status: OrchestrationV2ProviderTurn["status"], -): Extract["status"] { +): Extract["status"] { switch (status) { case "completed": return "completed"; @@ -677,7 +653,7 @@ const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffe CodexTurnStartParamsWithCollaborationMode, ); const isProviderAdapterRuntimeRequestResponseError = Schema.is( - ProviderAdapterRuntimeRequestResponseError, + ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, ); function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { @@ -724,7 +700,7 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { export function buildCodexTurnStartParams(input: { readonly nativeThreadId: string; readonly codexInput: ReadonlyArray; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly modelSelection: ModelSelection; readonly hasT3Mcp?: boolean; readonly browserToolsAvailable?: boolean; @@ -732,7 +708,7 @@ export function buildCodexTurnStartParams(input: { /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; /** What the thread's MCP Apps want the agent to know (`ui/update-model-context`). */ - readonly appContext?: ProviderAdapterV2TurnInput["appContext"]; + readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; }) { return Effect.gen(function* () { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); @@ -851,7 +827,7 @@ function getNativeThreadId(providerThread: OrchestrationV2ProviderThread) { function providerThreadFromCodexThread(input: { readonly appThreadId: ThreadId | null; - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly ownerNodeId: OrchestrationV2ProviderThread["ownerNodeId"]; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; readonly providerInstanceId: ProviderInstanceId; @@ -983,7 +959,7 @@ const countTerminalTurnsAfterBoundary = ( }; const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkRollbackTurnCount")( - function* (input: ProviderAdapterV2ForkThreadInput) { + function* (input: ProviderAdapter.ProviderAdapterV2ForkThreadInput) { if (input.providerTurnId === undefined || input.sourceProviderTurns === undefined) { return 0; } @@ -993,7 +969,7 @@ const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkR input.providerTurnId, ); if (rollbackTurnCount === null) { - return yield* new ProviderAdapterForkThreadError({ + return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ driver: CODEX_PROVIDER, providerThreadId: input.sourceProviderThread.id, cause: `Cannot fork Codex thread from provider turn ${input.providerTurnId}: source turn was not found in provider thread ${input.sourceProviderThread.id}.`, @@ -1012,7 +988,7 @@ const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkR * only for source turns that predate native turn references. */ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBoundary")(function* ( - input: ProviderAdapterV2ForkThreadInput, + input: ProviderAdapter.ProviderAdapterV2ForkThreadInput, ) { const rollbackTurnCount = yield* resolveCodexForkRollbackTurnCount(input); if (input.providerTurnId === undefined || input.sourceProviderTurns === undefined) { @@ -1066,14 +1042,14 @@ const readCodexThreadHistoryMetadata = Effect.fn("CodexAdapterV2.readThreadHisto ); export const resolveCodexRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveRollbackTurnCount")( - function* (input: ProviderAdapterV2RollbackThreadInput) { + function* (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) { const providerTurns = input.providerThreadTurns; switch (input.target.type) { case "thread_start": return providerTurns.filter(isTerminalProviderTurn).length; case "provider_turn": { if (input.target.providerTurn.providerThreadId !== input.providerThread.id) { - return yield* new ProviderAdapterRollbackThreadError({ + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: CODEX_PROVIDER, providerThreadId: input.providerThread.id, cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${input.target.providerTurn.id}: target turn belongs to provider thread ${input.target.providerTurn.providerThreadId}.`, @@ -1085,7 +1061,7 @@ export const resolveCodexRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveRo input.target.providerTurn.id, ); if (rollbackTurnCount === null) { - return yield* new ProviderAdapterRollbackThreadError({ + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: CODEX_PROVIDER, providerThreadId: input.providerThread.id, cause: `Cannot roll back Codex thread ${input.providerThread.id} to provider turn ${input.target.providerTurn.id}: target turn was not found in durable provider turn history.`, @@ -1129,10 +1105,10 @@ interface ActiveCodexTurnContext { readonly failure: OrchestrationV2ProviderFailure; }; readonly nativeStartReady?: Deferred.Deferred; - readonly input: ProviderAdapterV2TurnInput; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; readonly projectionAppThread: OrchestrationV2AppThread; readonly projectionThreadId: ThreadId; - readonly projectionRunId: ProviderAdapterV2TurnInput["runId"] | null; + readonly projectionRunId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; readonly nativeTurnId: string; readonly providerThread: OrchestrationV2ProviderThread; readonly providerTurnId: ProviderTurnId; @@ -1177,7 +1153,10 @@ function isPersistentCodexDynamicTool(item: CodexDynamicToolItem): boolean { return Reflect.get(input, "persistent") === true; } -type CodexRootTerminalEvent = Extract; +type CodexRootTerminalEvent = Extract< + ProviderAdapter.ProviderAdapterV2Event, + { readonly type: "turn.terminal" } +>; /** A completed root turn whose run stays open for the goal turn Codex starts next. */ interface CodexGoalHold { @@ -1294,12 +1273,12 @@ export interface CodexAppServerClientFactoryShape { readonly instanceId: ProviderInstanceId; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly settings: CodexSettings; readonly environment: NodeJS.ProcessEnv; }) => Effect.Effect< CodexClient.CodexAppServerClient["Service"], - ProviderAdapterOpenSessionError, + ProviderAdapter.ProviderAdapterOpenSessionError, Scope.Scope >; } @@ -1319,7 +1298,7 @@ export const CODEX_THREAD_CONFIG = { "tools.update_plan.enabled": true } as cons export function codexThreadRuntimeParams(input: { readonly threadId: ThreadId | null; readonly modelSelection?: { readonly model: string }; - readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; }): { readonly cwd?: string; readonly model?: string; @@ -1417,7 +1396,7 @@ const layerCodexAppServerClientFactoryCommand = ( Effect.provideService(Scope.Scope, scope), Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, cause, @@ -1434,7 +1413,7 @@ const layerCodexAppServerClientFactoryCommand = ( ); export function makeCodexAppServerProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; }): CodexClient.CodexAppServerClientOptions["logger"] | undefined { @@ -1510,12 +1489,12 @@ function isSensitiveCodexProtocolKey(key: string): boolean { export const layerAppServerClientFactory: Layer.Layer< CodexAppServerClientFactory, never, - ChildProcessSpawner.ChildProcessSpawner | ProviderEventLoggers + ChildProcessSpawner.ChildProcessSpawner | ProviderEventLoggers.ProviderEventLoggers > = Layer.effect( CodexAppServerClientFactory, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const { native: nativeEventLogger } = yield* ProviderEventLoggers; + const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; return CodexAppServerClientFactory.of({ open: (input) => @@ -1536,7 +1515,7 @@ export const layerAppServerClientFactory: Layer.Layer< Effect.provideService(Scope.Scope, scope), Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, cause, @@ -1569,7 +1548,7 @@ export type CodexAdapterV2DriverEnv = | CodexAppServerClientFactory | Crypto.Crypto | FileSystem.FileSystem - | IdAllocatorV2 + | IdAllocator.IdAllocatorV2 | Path.Path | ServerConfig; @@ -1579,11 +1558,11 @@ export const createCodexAdapterV2 = ( ) => Effect.gen(function* () { const clientFactory = yield* CodexAppServerClientFactory; - const continuationRequests = yield* ProviderContinuationRequests; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const hostEnvironment = yield* HostProcessEnvironment; - const idAllocator = yield* IdAllocatorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; const homeLayout = yield* resolveCodexHomeLayout(config); @@ -1628,18 +1607,22 @@ export const CodexAdapterV2Driver: ProviderAdapterDriver = Layer.effect( - ProviderAdapterV2, + ProviderAdapter.ProviderAdapterV2, Effect.gen(function* () { const clientFactory = yield* CodexAppServerClientFactory; - const continuationRequests = yield* ProviderContinuationRequests; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const hostEnvironment = yield* HostProcessEnvironment; - const idAllocator = yield* IdAllocatorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; return makeCodexAdapterV2({ @@ -1670,7 +1653,7 @@ export interface CodexAdapterV2Options { readonly resolveRuntime?: Effect.Effect; readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; - readonly idAllocator: IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; /** * Sink for post-settle background command completions so the orchestrator @@ -1678,15 +1661,19 @@ export interface CodexAdapterV2Options { * projection-only handling for late item completions. */ readonly continuationRequests?: { - readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; + readonly offer: ( + request: ProviderContinuationRequests.ProviderContinuationRequest, + ) => Effect.Effect; }; } -export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): ProviderAdapterV2Shape { +export function makeCodexAdapterV2( + adapterOptions: CodexAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2["Service"] { const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests; - return ProviderAdapterV2.of({ + return ProviderAdapter.ProviderAdapterV2.of({ instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), @@ -1700,7 +1687,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi : yield* adapterOptions.resolveRuntime.pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, cause, @@ -1800,7 +1787,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi model: input.modelSelection.model, now, }); - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const rateLimitSnapshot = yield* Ref.make(undefined); const limitedTurnItems = yield* Ref.make( new Map>(), @@ -1833,7 +1820,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi context.nativeTurnId, ).hasSubagents = true; }; - const pendingRootTurns = yield* Ref.make(new Map()); + const pendingRootTurns = yield* Ref.make( + new Map(), + ); const turnWaiters = yield* Ref.make(new Map>()); const subagentThreads = yield* Ref.make(new Map()); const subagentSelections = new Map>(); @@ -1893,7 +1882,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi const latestGoalTurnId = (providerTurnId: ProviderTurnId) => goalRuns.get(providerTurnId)?.at(-1)?.providerTurnId ?? providerTurnId; - const emitProviderEvent = (event: ProviderAdapterV2Event) => + const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); /** Writes the thread's current native goal onto its root provider thread. */ @@ -1942,7 +1931,6 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi type: "turn_item.updated", driver: CODEX_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CODEX_PROVIDER, threadId: context.projectionThreadId, runId: context.projectionRunId, @@ -1960,7 +1948,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }); const registerRootTurn = (input: { - readonly turnInput: ProviderAdapterV2TurnInput; + readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; readonly nativeTurnId: string; readonly startedAt: DateTime.Utc; readonly waitForNativeStart?: boolean; @@ -3143,7 +3131,11 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }); const toCodexInput = ( - turnInput: Pick, + turnInput: Pick< + | ProviderAdapter.ProviderAdapterV2TurnInput + | ProviderAdapter.ProviderAdapterV2SteerInput, + "message" + >, ) => Effect.gen(function* () { const inputItems: Array = []; @@ -4539,7 +4531,6 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi type: "turn_item.updated", driver: CODEX_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CODEX_PROVIDER, threadId: context.projectionThreadId, runId: context.projectionRunId, @@ -5584,7 +5575,6 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi } if (current.status === "failed" && current.failure.class === "usage_limit") { const item = makeProviderFailureTurnItem({ - idAllocator, driver: CODEX_PROVIDER, threadId: context.input.threadId, runId: context.input.runId, @@ -5735,7 +5725,6 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi type: "turn_item.updated", driver: CODEX_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: CODEX_PROVIDER, threadId: input.context.projectionThreadId, runId: input.context.projectionRunId, @@ -6000,7 +5989,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi * assistant text, as Claude's own `/goal` output is. */ const completeGoalCommandTurn = Effect.fnUntraced(function* ( - turnInput: ProviderAdapterV2TurnInput, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, reply: string, ) { const now = yield* DateTime.now; @@ -6104,7 +6093,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi * Stop also reaches. */ const resolveGoalStopTarget = Effect.fnUntraced(function* ( - turnInput: ProviderAdapterV2InterruptInput, + turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput, ) { const goalThreadId = turnInput.providerThread.nativeThreadRef?.nativeId; if (goalThreadId == null) { @@ -6172,7 +6161,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi /** Starts a native turn with this run's full turn configuration. */ const startNativeTurn = ( - turnInput: ProviderAdapterV2TurnInput, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, codexInput: ReadonlyArray, ) => Effect.gen(function* () { @@ -6227,7 +6216,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi * turn Codex starts on its own reuses the last turn's settings. Codex * continues later goal turns itself. Other commands settle with a reply. */ - const runGoalCommand = (turnInput: ProviderAdapterV2TurnInput, command: CodexGoalCommand) => + const runGoalCommand = ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + command: CodexGoalCommand, + ) => Effect.gen(function* () { const threadId = yield* getNativeThreadId(turnInput.providerThread); // Goal notifications during the command belong to this run's snapshot. @@ -6326,7 +6318,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: CODEX_PROVIDER, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -6339,8 +6331,12 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi // MCP Apps reach their server through Codex's own MCP client, so any // server the user configured for Codex works without T3 connecting to it. const mcpAppsError = (detail: string) => (cause: unknown) => - new ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail, payload: cause }); - const mcpApps: ProviderAdapterV2McpApps = { + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CODEX_PROVIDER, + detail, + payload: cause, + }); + const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { listTools: (toolsInput) => Effect.gen(function* () { const threadId = yield* getNativeThreadId(toolsInput.providerThread); @@ -6394,7 +6390,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe(Effect.mapError(mcpAppsError("Codex MCP resource read failed."))), }; - const runtime: ProviderAdapterV2SessionRuntime = { + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, @@ -6471,7 +6467,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), Effect.mapError( (cause) => - new ProviderAdapterEnsureThreadError({ + new ProviderAdapter.ProviderAdapterEnsureThreadError({ driver: CODEX_PROVIDER, threadId: threadInput.threadId, cause: normalizeCodexCause(cause), @@ -6535,7 +6531,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterResumeThreadError({ + new ProviderAdapter.ProviderAdapterResumeThreadError({ driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, providerThreadId: threadInput.providerThread.id, @@ -6563,7 +6559,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: CODEX_PROVIDER, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -6593,7 +6589,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail: "Failed to inject historical context", cause, @@ -6617,7 +6613,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: CODEX_PROVIDER, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -6655,7 +6651,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterSteerRunError({ + new ProviderAdapter.ProviderAdapterSteerRunError({ driver: CODEX_PROVIDER, providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, @@ -6674,7 +6670,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.mapError((cause) => cause._tag === "ProviderAdapterProtocolError" ? cause - : new ProviderAdapterProtocolError({ + : new ProviderAdapter.ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail: `Failed to unload Codex thread for provider thread ${unloadInput.providerThread.id}`, cause: normalizeCodexCause(cause), @@ -7061,7 +7057,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterInterruptError({ + new ProviderAdapter.ProviderAdapterInterruptError({ driver: CODEX_PROVIDER, providerThreadId: requestedInput.providerThread.id, providerTurnId: requestedInput.providerTurnId, @@ -7075,7 +7071,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi String(requestInput.requestId), ); if (pending === undefined) { - return yield* new ProviderAdapterRuntimeRequestResponseError({ + return yield* new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: CODEX_PROVIDER, requestId: requestInput.requestId, cause: toProtocolError( @@ -7085,7 +7081,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi } if (pending.type === "user_input") { if (requestInput.answers === undefined) { - return yield* new ProviderAdapterRuntimeRequestResponseError({ + return yield* new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: CODEX_PROVIDER, requestId: requestInput.requestId, cause: toProtocolError( @@ -7097,7 +7093,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi return; } if (requestInput.decision === undefined) { - return yield* new ProviderAdapterRuntimeRequestResponseError({ + return yield* new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: CODEX_PROVIDER, requestId: requestInput.requestId, cause: toProtocolError( @@ -7110,7 +7106,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.mapError((cause) => isProviderAdapterRuntimeRequestResponseError(cause) ? cause - : new ProviderAdapterRuntimeRequestResponseError({ + : new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ driver: CODEX_PROVIDER, requestId: requestInput.requestId, cause, @@ -7134,7 +7130,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail: "Failed to upload Codex thread feedback.", payload: cause, @@ -7167,7 +7163,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterReadThreadSnapshotError({ + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.providerThread.id, cause, @@ -7200,7 +7196,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.andThen(readCodexThreadHistoryMetadata(client.raw, threadId)), ); if (historyMode !== "paginated") { - return yield* new ProviderAdapterRollbackThreadError({ + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.providerThread.id, cause: `Cannot roll back Codex thread ${threadId}: the thread uses legacy history, which Codex 0.156 cannot revert.`, @@ -7244,7 +7240,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterRollbackThreadError({ + new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.providerThread.id, cause: normalizeCodexCause(cause), @@ -7275,7 +7271,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), Effect.mapError( (cause) => - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.sourceProviderThread.id, cause: normalizeCodexCause(cause), @@ -7291,7 +7287,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.andThen(readCodexThreadHistoryMetadata(client.raw, response.thread.id)), ); if (historyMode !== "paginated") { - return yield* new ProviderAdapterForkThreadError({ + return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.sourceProviderThread.id, cause: `Cannot fork Codex thread ${threadId} at provider turn ${threadInput.providerTurnId}: the source turn has no native Codex turn reference, and the forked thread uses legacy history which Codex 0.156 cannot revert.`, @@ -7303,7 +7299,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), Effect.mapError( (cause) => - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.sourceProviderThread.id, cause: normalizeCodexCause(cause), @@ -7328,7 +7324,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterForkThreadError({ + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: CODEX_PROVIDER, providerThreadId: threadInput.sourceProviderThread.id, cause: normalizeCodexCause(cause), @@ -7340,7 +7336,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe( Effect.mapError( (cause) => - new ProviderAdapterOpenSessionError({ + new ProviderAdapter.ProviderAdapterOpenSessionError({ driver: CODEX_PROVIDER, providerSessionId: input.providerSessionId, cause, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 3f2f6581f90d..fa347db3478d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -28,7 +28,7 @@ import { cursorSdkModelSelection, makeCursorAgentOptions, } from "@t3tools/provider-cursor/testing"; -import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts"; const CursorAgentSdkReplayTranscript = Schema.Struct({ @@ -625,7 +625,7 @@ function recordingRuntimePolicy(input: { readonly cwd: string; readonly interactionMode: "default" | "plan"; readonly override?: Pick; -}): ProviderAdapterV2RuntimePolicy { +}): ProviderAdapter.ProviderAdapterV2RuntimePolicy { return { runtimeMode: "full-access", interactionMode: input.interactionMode, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index a21f16853b9b..960d29beb4e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -26,7 +26,7 @@ import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as RuntimePolicy from "../RuntimePolicy.ts"; import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; import { @@ -68,7 +68,7 @@ function runtimePolicy(input: { readonly approvalPolicy?: unknown; readonly sandboxPolicy?: unknown; }) { - return ProviderAdapterV2RuntimePolicy.make({ + return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: input.runtimeMode, interactionMode: "default", cwd: "/workspace", @@ -314,7 +314,7 @@ describe("Grok launch permission mode", () => { // Opens a session through the adapter's own Grok runtime factory and returns // the argv it tried to launch. The spawn fails after recording, so no // process starts. - const launchArgs = (runtimePolicy: ProviderAdapterV2RuntimePolicy) => + const launchArgs = (runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy) => Effect.gen(function* () { const launches: Array> = []; const childProcessSpawner = ChildProcessSpawner.make((command) => { @@ -352,9 +352,9 @@ describe("Grok launch permission mode", () => { const policy = ( runtimeMode: RuntimeMode, - override: Partial = {}, + override: Partial = {}, ) => - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode, interactionMode: "default", cwd: process.cwd(), diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 37de61f072ce..467ad620d534 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -13,7 +13,7 @@ import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META } from "@t3tools/provider-grok/testing"; import { makeXAiPromptCompletionRuntime } from "@t3tools/provider-grok/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 1d10bb8e50e1..b695b39fa8d0 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -36,7 +36,7 @@ import { } from "@t3tools/provider-muse/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ff87c347641e..789ed7e77859 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -38,14 +38,10 @@ import * as Exit from "effect/Exit"; import { TestClock } from "effect/testing"; import { describe } from "vite-plus/test"; -import type { - ProviderAdapterV2Event, - ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { OPENCODE_2_STILL_STOPPING, OPENCODE_PROVIDER, @@ -347,21 +343,27 @@ const resumed = ( return { runtime, thread }; }); -const requestOf = (runtime: ProviderAdapterV2SessionRuntime) => +const requestOf = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) => runtime.events.pipe( Stream.filter( - (event): event is Extract => - event.type === "runtime_request.updated", + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "runtime_request.updated" } + > => event.type === "runtime_request.updated", ), Stream.map((event) => event.runtimeRequest), Stream.runHead, Effect.map(Option.getOrUndefined), ); -const terminalOf = (runtime: ProviderAdapterV2SessionRuntime) => +const terminalOf = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) => runtime.events.pipe( Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.runHead, @@ -637,7 +639,7 @@ describe("OpenCode2 adapter", () => { * The single reader of the runtime's events: resolves `attached` once the * running background child has its thread, then returns the turn's terminal. */ - const watchBackgroundTurn = (runtime: ProviderAdapterV2SessionRuntime) => + const watchBackgroundTurn = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) => Effect.gen(function* () { const attached = yield* Deferred.make(); const terminal = yield* runtime.events.pipe( @@ -647,7 +649,9 @@ describe("OpenCode2 adapter", () => { : Effect.void, ), Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.runHead, @@ -767,7 +771,9 @@ describe("OpenCode2 adapter", () => { : Effect.void, ), Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.runHead, @@ -809,7 +815,9 @@ describe("OpenCode2 adapter", () => { const firstEnded = yield* Deferred.make(); const ended = yield* runtime.events.pipe( Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.tap(() => Deferred.succeed(firstEnded, undefined)), @@ -1110,7 +1118,7 @@ describe("OpenCode2 adapter", () => { event("session.execution.succeeded", { sessionID: SESSION }), ]; /** The runtime's events, with the background subagent's statuses and the middle session's turns. */ - const watchNested = (runtime: ProviderAdapterV2SessionRuntime) => + const watchNested = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime) => Effect.gen(function* () { const deep: Array = []; const middleTurns = new Map(); @@ -1325,7 +1333,7 @@ describe("OpenCode2 adapter", () => { attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), }); const stopFirstTurn = ( - runtime: ProviderAdapterV2SessionRuntime, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, thread: OrchestrationV2ProviderThread, ) => Effect.gen(function* () { @@ -1336,10 +1344,12 @@ describe("OpenCode2 adapter", () => { yield* TestClock.adjust("11 seconds"); yield* Fiber.join(interrupt); }); - const terminals = (runtime: ProviderAdapterV2SessionRuntime, count: number) => + const terminals = (runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, count: number) => runtime.events.pipe( Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.take(count), @@ -2113,7 +2123,9 @@ describe("OpenCode2 adapter", () => { }), ), Stream.filter( - (event): event is Extract => + ( + event, + ): event is Extract => event.type === "turn.terminal", ), Stream.tap(() => Deferred.succeed(firstEnded, undefined)), @@ -2331,7 +2343,7 @@ describe("OpenCode2 adapter", () => { replyData("session.form.list", forms), ]; - const turnItems = (collected: ReadonlyArray) => + const turnItems = (collected: ReadonlyArray) => collected.flatMap((event) => event.type === "turn_item.updated" ? [`${event.turnItem.type}:${event.turnItem.status}`] : [], ); @@ -3043,7 +3055,7 @@ describe("OpenCode2 adapter", () => { Effect.gen(function* () { const kept = "msg_t3_turn_run-attempt:kept:1"; const dropped = "msg_t3_turn_run-attempt:dropped:1"; - const offers: Array = []; + const offers: Array = []; const { runtime, thread } = yield* resumed([ out("message.list", ""), reply("message.list", { @@ -3727,7 +3739,7 @@ describe("OpenCode2 adapter", () => { */ const continued = (after: ReadonlyArray) => Effect.gen(function* () { - const offers: Array = []; + const offers: Array = []; const { runtime, thread } = yield* resumed([ ...backgroundLaunch(CHILD), event("session.execution.succeeded", { sessionID: SESSION }), @@ -3867,7 +3879,7 @@ describe("OpenCode2 adapter", () => { Effect.gen(function* () { const reportText = `\nCHILD_OK\n`; const steerId = `msg_t3_steer_${SESSION}:message:opencode2-adapter:steer`; - const offers: Array = []; + const offers: Array = []; const { runtime, thread } = yield* resumed([ ...backgroundLaunch(CHILD), event("session.execution.succeeded", { sessionID: SESSION }), diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 36d0e35ddced..5c982997a92f 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { OpenCodeAdapterV2Driver } from "@t3tools/provider-opencode/server"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index 4d4950bcb4b7..6d5e0b92d87f 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -24,16 +24,11 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { - ProviderAdapterV2Event, - ProviderAdapterV2InterruptInput, - ProviderAdapterV2Shape, - ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); @@ -61,10 +56,10 @@ const stopEarlierBackgroundWork = ({ Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace("background-work-stop"); - const events = yield* Queue.unbounded(); - const started: ProviderAdapterV2TurnInput[] = []; - const interrupts: ProviderAdapterV2InterruptInput[] = []; - const adapter: ProviderAdapterV2Shape = { + const events = yield* Queue.unbounded(); + const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; + const interrupts: ProviderAdapter.ProviderAdapterV2InterruptInput[] = []; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { instanceId, driver, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 6be935c9c23b..45bf03610316 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -252,7 +252,7 @@ export const layer: Layer.Layer< ); function makeCheckpointTurnItem(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly run: OrchestrationV2Run; readonly rootNode: OrchestrationV2ExecutionNode; readonly providerThread: OrchestrationV2ProviderThread; diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 217a73697133..018b4f8f49b1 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -26,7 +26,7 @@ import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { ProviderAdapterV2RollbackThreadInput } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimePolicy from "./RuntimePolicy.ts"; @@ -457,7 +457,7 @@ it.effect.each([ Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open: () => Effect.succeed({ - rollbackThread: (input: ProviderAdapterV2RollbackThreadInput) => + rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => Effect.gen(function* () { const count = yield* resolveCodexRollbackTurnCount(input); assert.equal(count, 2 - targetOrdinal); diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index f9747abe2093..acacfed521c6 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -20,10 +20,10 @@ import { } from "./CheckpointRestoreSafety.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; -import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { ProviderAdapterV2RollbackTarget } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -85,7 +85,7 @@ export const layer: Layer.Layer< never, | CheckpointServiceV2 | EventSinkV2 - | IdAllocatorV2 + | IdAllocator.IdAllocatorV2 | ProjectionStoreV2 | ProviderSessionManagerV2 | RuntimePolicyV2 @@ -98,7 +98,7 @@ export const layer: Layer.Layer< Effect.gen(function* () { const checkpoints = yield* CheckpointServiceV2; const eventSink = yield* EventSinkV2; - const ids = yield* IdAllocatorV2; + const ids = yield* IdAllocator.IdAllocatorV2; const projections = yield* ProjectionStoreV2; const sessions = yield* ProviderSessionManagerV2; const runtimePolicy = yield* RuntimePolicyV2; @@ -225,7 +225,7 @@ export const layer: Layer.Layer< turn.providerThreadId === providerThread.id && (turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId)), ); - const rollbackTarget: ProviderAdapterV2RollbackTarget = + const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget = targetOrdinal === 0 ? { type: "thread_start", diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index c0bf30311a24..913e53658e2c 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -172,7 +172,7 @@ export const checkpointRefForScopeOrdinal = Effect.fn("checkpointRefForScopeOrdi ); function checkpointIdForScopeOrdinal( - idAllocator: IdAllocator.IdAllocatorV2Shape, + idAllocator: IdAllocator.IdAllocatorV2["Service"], input: { readonly scopeId: CheckpointScopeId; readonly ordinalWithinScope: number; @@ -185,7 +185,7 @@ function checkpointIdForScopeOrdinal( } function makeRootRunScope(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly threadId: ThreadId; readonly runId: RunId; readonly rootNodeId: NodeId; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 0335d921da3e..bbab4b5fd452 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -1,4 +1,4 @@ -import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { assert, describe, it } from "@effect/vitest"; import { ContextHandoffId, @@ -501,7 +501,7 @@ describe("handoff delivery", () => { providerThread, budget: 16_000, alreadyDeliveredItemIds: new Set(), - inject: (value: ProviderAdapterV2HistoricalContext) => + inject: (value: ProviderAdapter.ProviderAdapterV2HistoricalContext) => Effect.sync(() => { history.push(...historyResponseItems(value.messages, value.context)); return true; @@ -588,7 +588,7 @@ describe("handoff delivery", () => { ...handoff, id: ContextHandoffId.make(`handoff:retry:${index}`), })); - let captured: ProviderAdapterV2HistoricalContext | undefined; + let captured: ProviderAdapter.ProviderAdapterV2HistoricalContext | undefined; const result = yield* deliverContextHandoffs({ handoffs: many, providerThread, diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 534217a58a20..30f5873b9cf0 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -2,7 +2,7 @@ import type { OrchestrationV2ContextHandoff, OrchestrationV2ProviderThread, } from "@t3tools/contracts"; -import type { ProviderAdapterV2HistoricalContext } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ContextHandoffBudgetError } from "@t3tools/provider-core/server/failure"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -25,7 +25,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH readonly deferInline?: boolean; readonly alreadyDeliveredItemIds: ReadonlySet; readonly inject?: ( - history: ProviderAdapterV2HistoricalContext, + history: ProviderAdapter.ProviderAdapterV2HistoricalContext, ) => Effect.Effect; readonly persist: (handoff: OrchestrationV2ContextHandoff) => Effect.Effect; }) { diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 1c74ee18d7a9..044f3fd8da9f 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -29,7 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ed2ae984501e..3f93674cd5d9 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -36,10 +36,10 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import { continueRestartedRun } from "./RestartContinuation.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerPlatformTest = Layer.merge( NodeServices.layer, @@ -74,7 +74,7 @@ const orchestrationAdapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: () => Effect.die("sessions are not used by delegated completion tests"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const providerInstance = { instanceId: modelSelection.instanceId, driverKind: driver, diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts index 044b1b66bcdc..8e2fd54f06ff 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts @@ -13,9 +13,9 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { DispatchModeLimit } from "./DispatchModeLimit.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const instanceId = ProviderInstanceId.make("codex"); const adapter = { @@ -24,7 +24,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("No provider process needed for metadata commands"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const layerDatabase = SqlitePersistence.layerMemory; const layerTest = Layer.mergeAll( layerDatabase, diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 15d29d30f8ac..abdf28155a06 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -29,7 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 55d93ded6e6e..b3d025ed0bda 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -54,7 +54,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; -import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; @@ -67,7 +67,7 @@ import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry. import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 76a4bd8c1bec..8e5fca79cf55 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -25,9 +25,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const instanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId, model: "gpt-5.1-codex" }; @@ -37,7 +37,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("No provider process needed for metadata controls"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const layerDatabase = SqlitePersistence.layerMemory; const layerTest = Layer.mergeAll( layerDatabase, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c85f11c56835..4f870a606e00 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -91,7 +91,7 @@ import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; -import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { @@ -107,9 +107,8 @@ import { type ProjectionCheckpointContext, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; -import { ProviderContinuationRequests } from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as RunExecutionService from "./RunExecutionService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; @@ -128,6 +127,7 @@ import { ThreadForkServiceV2, } from "./ThreadForkService.ts"; import { planThreadDeletion } from "./ThreadDeletion.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; export class OrchestratorDispatchError extends Schema.TaggedError()( "OrchestratorDispatchError", @@ -800,7 +800,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const contextHandoffService = yield* ContextHandoffServiceV2; const eventSink = yield* EventSinkV2; const commandReceipts = yield* CommandReceiptStoreV2; - const idAllocator = yield* IdAllocatorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const projects = yield* ProjectStore.ProjectStoreV2; const projectionStore = yield* ProjectionStoreV2; const effectOutbox = yield* EffectOutbox.EffectOutboxV2; @@ -823,7 +823,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const providerAdapters = yield* ProviderAdapterRegistryV2; - const continuationRequests = yield* ProviderContinuationRequests; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const providerSessions = yield* ProviderSessionManagerV2; const providerSwitchService = yield* ProviderSwitchServiceV2; const runtimePolicy = yield* RuntimePolicyV2; @@ -857,7 +857,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const providerSessionIdFor = (input: { - readonly adapter: ProviderAdapterV2Shape; + readonly adapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly providerInstanceId: ProviderInstanceId; readonly threadId: ThreadId; }) => @@ -10955,7 +10955,7 @@ export const layer: Layer.Layer< | ContextHandoffServiceV2 | EffectOutbox.EffectOutboxV2 | EventSinkV2 - | IdAllocatorV2 + | IdAllocator.IdAllocatorV2 | ProjectStore.ProjectStoreV2 | ProviderAdapterRegistryV2 | ProviderSessionManagerV2 diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts index 3e8f6dfa297b..2f2c545377eb 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts @@ -18,36 +18,35 @@ import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow"; import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import { - ProviderAdapterOpenSessionError, - type ProviderAdapterV2Shape, -} from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const driver = ProviderDriverKind.make("codex"); const personalId = ProviderInstanceId.make("codex_personal"); const workId = ProviderInstanceId.make("codex_work"); -const makeAdapter = (instanceId: ProviderInstanceId): ProviderAdapterV2Shape => +const makeAdapter = ( + instanceId: ProviderInstanceId, +): ProviderAdapter.ProviderAdapterV2["Service"] => ({ instanceId, driver, getCapabilities: () => Effect.die("capabilities are not used by this registry test"), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: () => Effect.die("sessions are not used by this registry test"), - }) as ProviderAdapterV2Shape; + }) as ProviderAdapter.ProviderAdapterV2["Service"]; const makeInstance = ( instanceId: ProviderInstanceId, - orchestrationAdapter: ProviderAdapterV2Shape, + orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"], ): ProviderInstance => ({ instanceId, driverKind: driver, @@ -101,7 +100,11 @@ const lifecycleConfigMap: ProviderInstanceConfigMap = { const lifecycleAdapter = makeAdapter(lifecycleInstanceId); const makeLifecycleDriver = ( - create: Effect.Effect, + create: Effect.Effect< + ProviderAdapter.ProviderAdapterV2["Service"], + ProviderAdapterDriverCreateError, + Scope.Scope + >, ): ProviderAdapterDriver> => ({ driverKind: lifecycleDriver, configSchema: Schema.Struct({}), @@ -263,7 +266,7 @@ it.effect( }, }) .pipe(Effect.flip); - assert.instanceOf(error, ProviderAdapterOpenSessionError); + assert.instanceOf(error, ProviderAdapter.ProviderAdapterOpenSessionError); assert.instanceOf(error.cause, ProviderSetupError); }), ); @@ -287,7 +290,7 @@ it.effect("interrupts admitted session startup when a shared peer signs out", () authenticate: () => Effect.void, logout: Effect.void, }); - const adapter: ProviderAdapterV2Shape = { + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { ...workAdapter, openSession: () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts index 984fd586e5ea..9811907f2502 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts @@ -48,11 +48,11 @@ export type ProviderAdapterRegistryV2Error = typeof ProviderAdapterRegistryV2Err export interface ProviderAdapterRegistryV2Shape { readonly get: ( instanceId: ProviderInstanceId, - ) => Effect.Effect; + ) => Effect.Effect; readonly list: () => Effect.Effect>; readonly getMetadata?: (instanceId: ProviderInstanceId) => Effect.Effect< { - readonly driver: ProviderAdapter.ProviderAdapterV2Shape["driver"]; + readonly driver: ProviderAdapter.ProviderAdapterV2["Service"]["driver"]; readonly continuationKey: string; readonly enabled: boolean; readonly capabilities: OrchestrationV2ProviderCapabilities; @@ -134,7 +134,7 @@ export const layerFromProviderInstanceRegistry: Layer.Layer< ), ); }, - } satisfies ProviderAdapter.ProviderAdapterV2Shape); + } satisfies ProviderAdapter.ProviderAdapterV2["Service"]); }), ), list: () => @@ -169,7 +169,7 @@ export const ProviderAdapterRegistryBuildError = Schema.Union([ProviderAdapterDr export type ProviderAdapterRegistryBuildError = typeof ProviderAdapterRegistryBuildError.Type; function makeRegistry( - adapters: ReadonlyArray, + adapters: ReadonlyArray, ): ProviderAdapterRegistryV2Shape { return { get: (instanceId) => @@ -185,7 +185,7 @@ function makeRegistry( } export function layerFromAdapters( - adapters: ReadonlyArray, + adapters: ReadonlyArray, ): Layer.Layer { return Layer.succeed( ProviderAdapterRegistryV2, @@ -194,7 +194,7 @@ export function layerFromAdapters( } export function layerFromAdaptersEffect( - adapters: Effect.Effect, E, R>, + adapters: Effect.Effect, E, R>, ): Layer.Layer { return Layer.effect( ProviderAdapterRegistryV2, @@ -203,7 +203,7 @@ export function layerFromAdaptersEffect( } export function layerSingle( - adapter: ProviderAdapter.ProviderAdapterV2Shape, + adapter: ProviderAdapter.ProviderAdapterV2["Service"], ): Layer.Layer { return layerFromAdapters([adapter]); } @@ -217,7 +217,7 @@ const decodedConfigEnabled = (config: unknown): boolean | undefined => { }; interface LiveAdapterEntry { - readonly adapter: ProviderAdapter.ProviderAdapterV2Shape; + readonly adapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly scope: Scope.Closeable; readonly entry: ProviderInstanceConfig; } diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 6e99d80a30e4..a6afc4a4b2ff 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -19,7 +19,7 @@ import * as Semaphore from "effect/Semaphore"; import * as TestClock from "effect/testing/TestClock"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 928a5c909524..b8a57a88937f 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 355a73e2199c..02acf9b51da1 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -31,7 +31,7 @@ import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderFailureTurnItem } from "@t3tools/provider-core/server/failure"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { stripUnservedToolOutputImageBytes } from "./toolOutputImageBytes.ts"; @@ -41,7 +41,7 @@ export class ProviderEventNormalizeError extends Schema.TaggedError> { @@ -257,7 +257,7 @@ export interface ProviderEventIngestInput { readonly runId?: RunId; readonly nodeId?: NodeId; readonly rawEventId?: RawEventId; - readonly event: ProviderAdapterV2Event; + readonly event: ProviderAdapter.ProviderAdapterV2Event; readonly analyticsContext?: ProviderTurnAnalyticsContext; } @@ -657,7 +657,6 @@ export const layer: Layer.Layer< yield* makeDomainEvent(input, { type: "turn-item.updated", payload: makeProviderFailureTurnItem({ - idAllocator, driver: input.event.driver, threadId: input.threadId, runId: input.runId ?? null, diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index bb994377f4bf..7f5d935771b0 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -44,18 +44,11 @@ import * as EventSink from "./EventSink.ts"; import * as EventStore from "./EventStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { - ProviderAdapterEventStreamError, - type ProviderAdapterV2Event, - ProviderAdapterProtocolError, - type ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2Shape, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerTestDatabase = SqlitePersistence.layerMemory; const layerTestStores = Layer.merge(EventStore.layer, ProjectionStore.layer).pipe( @@ -167,7 +160,10 @@ interface TestProviderRuntimeState { readonly interruptCount: number; readonly resumeCount: number; readonly unloadedNativeThreadIds: ReadonlyArray; - readonly eventQueues: ReadonlyMap>; + readonly eventQueues: ReadonlyMap< + string, + Queue.Queue + >; } const emptyState: TestProviderRuntimeState = { @@ -189,7 +185,7 @@ const runtimePolicy = { runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), -} satisfies ProviderAdapterV2RuntimePolicy; +} satisfies ProviderAdapter.ProviderAdapterV2RuntimePolicy; function makeProviderSession(input: { readonly providerSessionId: ProviderSessionId; @@ -211,7 +207,7 @@ function makeProviderSession(input: { } function makeThreadCreatedEvent(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly threadId: ThreadId; readonly now: DateTime.Utc; readonly projectId?: ProjectId; @@ -264,7 +260,7 @@ function makeThreadCreatedEvent(input: { } function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly now: DateTime.Utc; @@ -299,7 +295,7 @@ function makeProviderThread(input: { function unimplemented(detail: string) { return Effect.fail( - new ProviderAdapterProtocolError({ + new ProviderAdapter.ProviderAdapterProtocolError({ driver: CODEX_DRIVER, detail, }), @@ -328,7 +324,7 @@ function makeProviderAdapter( /** Completed when a hanging scope close reaches its wedged finalizer. */ readonly scopeCloseReached?: Deferred.Deferred; } = {}, -): ProviderAdapterV2Shape { +): ProviderAdapter.ProviderAdapterV2["Service"] { const countClose = Effect.addFinalizer(() => Ref.update(state, (current) => ({ ...current, @@ -371,7 +367,7 @@ function makeProviderAdapter( ]); } const now = yield* DateTime.now; - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const session = makeProviderSession({ providerSessionId: input.providerSessionId, now, @@ -398,7 +394,7 @@ function makeProviderAdapter( providerSession: session, events: options.failEventStream ? Stream.fail( - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver: CODEX_DRIVER, providerSessionId: input.providerSessionId, cause: "process exited", @@ -442,7 +438,7 @@ function makeProviderAdapter( readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"), rollbackThread: () => unimplemented("rollbackThread unused in test"), forkThread: () => unimplemented("forkThread unused in test"), - } satisfies ProviderAdapterV2SessionRuntime; + } satisfies ProviderAdapter.ProviderAdapterV2SessionRuntime; }), }; } @@ -674,7 +670,7 @@ function runBrowserAccessScenario(input: { } function makePendingRuntimeRequestEvents(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly providerThread: OrchestrationV2ProviderThread; @@ -795,7 +791,7 @@ function makePendingRuntimeRequestEvents(input: { driver: CODEX_DRIVER, turnItem, }, - ] satisfies ReadonlyArray; + ] satisfies ReadonlyArray; return { events, providerEvents, requestId, nodeId }; }); } diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f467b8527e89..705bd4ec27e2 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -46,14 +46,7 @@ import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; -import { - ProviderAdapterEventStreamError, - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Error, - type ProviderAdapterV2Event, - type ProviderAdapterV2EventSubscription, - type ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -161,14 +154,20 @@ export interface ProviderSessionManagerV2Shape { readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly resumeFromSession?: OrchestrationV2ProviderSession; readonly initialNativeThreadId?: string; readonly initialProviderItemIdentityVersion?: 2; - }) => Effect.Effect; + }) => Effect.Effect< + ProviderAdapter.ProviderAdapterV2SessionRuntime, + ProviderSessionManagerV2Error + >; readonly get: ( providerSessionId: ProviderSessionId, - ) => Effect.Effect, ProviderSessionManagerV2Error>; + ) => Effect.Effect< + Option.Option, + ProviderSessionManagerV2Error + >; readonly close: ( providerSessionId: ProviderSessionId, ) => Effect.Effect; @@ -211,8 +210,8 @@ interface LiveSessionEntry { */ readonly mcpCredentialIdByThread: ReadonlyMap; readonly supportsMultipleProviderThreads: boolean; - readonly runtime: ProviderAdapterV2SessionRuntime; - readonly exposedRuntime: ProviderAdapterV2SessionRuntime; + readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; + readonly exposedRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly eventSubscribers: Ref.Ref< ReadonlyMap> >; @@ -244,10 +243,10 @@ interface IdleThreadUnload { } type ProviderSessionEventSignal = - | { readonly type: "event"; readonly event: ProviderAdapterV2Event } + | { readonly type: "event"; readonly event: ProviderAdapter.ProviderAdapterV2Event } | { readonly type: "failure"; - readonly cause: Cause.Cause; + readonly cause: Cause.Cause; }; export interface ProviderSessionManagerV2LayerOptions { @@ -279,7 +278,9 @@ function sessionKey(providerSessionId: ProviderSessionId): string { * Their node and transcript item are runless too, so they bypass the normal * per-run subscriber and are persisted by the session event pump. */ -function sessionScopedRuntimeRequestThreadId(event: ProviderAdapterV2Event): ThreadId | undefined { +function sessionScopedRuntimeRequestThreadId( + event: ProviderAdapter.ProviderAdapterV2Event, +): ThreadId | undefined { switch (event.type) { case "runtime_request.updated": return event.runtimeRequest.providerTurnId === null ? event.threadId : undefined; @@ -298,7 +299,9 @@ function sessionScopedRuntimeRequestThreadId(event: ProviderAdapterV2Event): Thr } function providerThreadRuntimeKey( - providerThread: Parameters[0]["providerThread"], + providerThread: Parameters< + ProviderAdapter.ProviderAdapterV2SessionRuntime["resumeThread"] + >[0]["providerThread"], ): string { const nativeThreadRef = providerThread.nativeThreadRef; return nativeThreadRef === null @@ -308,10 +311,10 @@ function providerThreadRuntimeKey( function providerThreadLoadKey(input: { readonly providerThread: Parameters< - ProviderAdapterV2SessionRuntime["resumeThread"] + ProviderAdapter.ProviderAdapterV2SessionRuntime["resumeThread"] >[0]["providerThread"]; readonly modelSelection?: ModelSelection; - readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; }): string { return JSON.stringify({ providerThread: providerThreadRuntimeKey(input.providerThread), @@ -575,7 +578,7 @@ export const layerWithOptions = ( const failSubscribers = (entry: LiveSessionEntry, detail: string) => Effect.gen(function* () { - const error = new ProviderAdapterEventStreamError({ + const error = new ProviderAdapter.ProviderAdapterEventStreamError({ driver: entry.runtime.driver, providerSessionId: entry.runtime.providerSessionId, cause: detail, @@ -617,7 +620,7 @@ export const layerWithOptions = ( fiber === null ? Effect.void : Fiber.interrupt(fiber).pipe(Effect.ignore); const writeProviderSessionEvents = (input: { - readonly runtime: ProviderAdapterV2SessionRuntime; + readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly threadIds: Iterable; readonly type: "provider-session.attached" | "provider-session.updated"; readonly payload: OrchestrationV2ProviderSession; @@ -1226,7 +1229,7 @@ export const layerWithOptions = ( const removeThreadAttachment = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; - readonly runtime: ProviderAdapterV2SessionRuntime; + readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; }) => Ref.update(sessions, (current) => { const key = sessionKey(input.providerSessionId); @@ -1289,7 +1292,7 @@ export const layerWithOptions = ( readonly providerInstanceId: ProviderInstanceId; }) => Effect.suspend(() => { - let attachedTo: ProviderAdapterV2SessionRuntime | undefined; + let attachedTo: ProviderAdapter.ProviderAdapterV2SessionRuntime | undefined; let preparedForCleanup: PreparedMcpCredential | undefined; let reservationDropped = false; const dropReservation = () => { @@ -1631,7 +1634,7 @@ export const layerWithOptions = ( subscribers: Ref.Ref< ReadonlyMap> >, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const queue = yield* Queue.unbounded(); const subscriberId = yield* Ref.getAndUpdate(nextSubscriberId, (value) => value + 1); @@ -1662,15 +1665,15 @@ export const layerWithOptions = ( ), Stream.ensuring(close), ); - return { events, close } satisfies ProviderAdapterV2EventSubscription; + return { events, close } satisfies ProviderAdapter.ProviderAdapterV2EventSubscription; }); const decorateRuntime = ( - runtime: ProviderAdapterV2SessionRuntime, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, eventSubscribers: Ref.Ref< ReadonlyMap> >, - ): ProviderAdapterV2SessionRuntime => { + ): ProviderAdapter.ProviderAdapterV2SessionRuntime => { const providerSessionId = runtime.providerSessionId; const subscribeEvents = makeEventSubscription(eventSubscribers); // Every provider's turn operations pass through here, so this is where they are @@ -1852,7 +1855,10 @@ export const layerWithOptions = ( const persistProviderSessionUpdate = ( entry: LiveSessionEntry, - event: Extract, + event: Extract< + ProviderAdapter.ProviderAdapterV2Event, + { readonly type: "provider_session.updated" } + >, ) => Effect.gen(function* () { const current = (yield* Ref.get(sessions)).get( @@ -1925,7 +1931,7 @@ export const layerWithOptions = ( .pipe( Effect.mapError( (cause) => - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver: entry.runtime.driver, providerSessionId: entry.runtime.providerSessionId, cause, @@ -1962,7 +1968,7 @@ export const layerWithOptions = ( const cause = Exit.isFailure(exit) ? exit.cause : Cause.fail( - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver: entry.runtime.driver, providerSessionId: entry.runtime.providerSessionId, cause: "Provider event stream ended unexpectedly.", @@ -2212,7 +2218,7 @@ export const layerWithOptions = ( Effect.gen(function* () { const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId)); if (entry === undefined) { - return Option.none(); + return Option.none(); } yield* touchActivity(providerSessionId); return Option.some(entry.exposedRuntime); diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index 712b9e6e10f3..d36721bbd6a1 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -12,10 +12,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import * as ProviderSwitch from "./ProviderSwitchService.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const driver = ProviderDriverKind.make("codex"); const currentInstanceId = ProviderInstanceId.make("codex_primary"); @@ -67,10 +67,12 @@ function deadSessionRecord( function layerTest( metadata: Readonly>, - planSelectionTransition: ProviderAdapterV2Shape["planSelectionTransition"] = () => + planSelectionTransition: ProviderAdapter.ProviderAdapterV2["Service"]["planSelectionTransition"] = () => Effect.succeed({ type: "restart_session" }), ) { - const adapter = (instanceId: ProviderInstanceId): ProviderAdapterV2Shape => ({ + const adapter = ( + instanceId: ProviderInstanceId, + ): ProviderAdapter.ProviderAdapterV2["Service"] => ({ instanceId, driver, getCapabilities: () => Effect.succeed(capabilitiesWithoutModelSwitch), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 42b3943e71d0..2ac00838018b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -24,7 +24,7 @@ import * as Stream from "effect/Stream"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; @@ -177,7 +177,7 @@ it.effect( updatedAt: now, lastError: null, }; - const runtime: ProviderAdapterV2SessionRuntime = { + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: providerInstanceId, driver, providerSessionId: oldSessionId, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 54e55c40a3cc..d6c12fab7ee1 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -32,7 +32,7 @@ import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { ProviderAdapterEventStreamError } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnStart from "./ProviderTurnStartService.ts"; import * as RunExecutionService from "./RunExecutionService.ts"; @@ -372,7 +372,7 @@ function makeLocalCommandHarness(input: { }).pipe( Effect.andThen( Effect.fail( - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver: providerThread.driver, providerSessionId, cause: input.ensureThreadFailure, @@ -385,7 +385,7 @@ function makeLocalCommandHarness(input: { driver: providerThread.driver, resumeThread: () => Effect.fail( - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver: providerThread.driver, providerSessionId, cause: "native thread is gone", diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index a928680b2402..e2c905b1e17a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -37,12 +37,7 @@ import { latestNativeContextUsage, } from "@t3tools/provider-core/server/handoffBudget"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; -import { - ProviderAdapterTurnStartError, - type ProviderAdapterV2Error, - type ProviderAdapterV2HistoricalContext, - type ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -184,15 +179,15 @@ export const layer: Layer.Layer< }; const makeDeliverySession = ( - session: ProviderAdapterV2SessionRuntime, + session: ProviderAdapter.ProviderAdapterV2SessionRuntime, startWithHandoffs: ( - input: Parameters[0], + input: Parameters[0], compact?: boolean, - ) => ReturnType, + ) => ReturnType, ) => { let deliver: typeof startWithHandoffs | undefined = startWithHandoffs; const start = ( - input: Parameters[0], + input: Parameters[0], compact = false, ) => Effect.suspend(() => { @@ -595,7 +590,7 @@ export const layer: Layer.Layer< // Only the provider's own thread load fails the run on the last attempt; // store, id and handoff failures around it keep their typed errors. const loadFromProvider = ( - load: Effect.Effect, + load: Effect.Effect, ) => Effect.gen(function* () { const loaded = yield* Effect.result(load); @@ -672,7 +667,7 @@ export const layer: Layer.Layer< const resumed = yield* Effect.result( uncertainDelivery ? Effect.fail( - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: session.driver, threadId: projection.thread.id, providerThreadId: providerThread.id, @@ -1143,7 +1138,7 @@ export const layer: Layer.Layer< ...(session.injectHistory === undefined ? {} : { - inject: (history: ProviderAdapterV2HistoricalContext) => + inject: (history: ProviderAdapter.ProviderAdapterV2HistoricalContext) => session.injectHistory!({ providerThread: runningProviderThread, ...history, @@ -1211,7 +1206,7 @@ export const layer: Layer.Layer< Effect.mapError((cause) => cause._tag === "ProviderAdapterTurnStartError" ? cause - : new ProviderAdapterTurnStartError({ + : new ProviderAdapter.ProviderAdapterTurnStartError({ driver: session.driver, threadId: projection.thread.id, providerThreadId: providerThread.id, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index c74346058077..708205663787 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -43,14 +43,7 @@ import * as CheckpointService from "./CheckpointService.ts"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterEventStreamError, - ProviderAdapterTurnStartError, - type ProviderAdapterV2Error, - type ProviderAdapterV2Event, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; @@ -95,7 +88,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => providerThreadId: ProviderThreadId.make("provider-thread:shared-runtime:second"), }; const firstTurnId = ProviderTurnId.make("provider-turn:shared-runtime:first"); - const turnEvent: ProviderAdapterV2Event = { + const turnEvent: ProviderAdapter.ProviderAdapterV2Event = { type: "provider_turn.updated", driver, threadId: first.threadId, @@ -111,7 +104,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => completedAt: null, }, }; - const messageEvent: ProviderAdapterV2Event = { + const messageEvent: ProviderAdapter.ProviderAdapterV2Event = { type: "message.updated", driver, message: { @@ -129,7 +122,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => updatedAt: now, }, }; - const terminalEvent: ProviderAdapterV2Event = { + const terminalEvent: ProviderAdapter.ProviderAdapterV2Event = { type: "turn.terminal", driver, providerThreadId: first.providerThreadId, @@ -181,7 +174,7 @@ it("leaves a child thread created after the root turn ended to the run that is l attemptId: RunAttemptId.make("attempt:late-child"), providerThreadId: ProviderThreadId.make("provider-thread:late-child"), }; - const childCreated = (childThreadId: ThreadId): ProviderAdapterV2Event => + const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event => ({ type: "app_thread.created", driver, @@ -193,7 +186,7 @@ it("leaves a child thread created after the root turn ended to the run that is l rootThreadId: threadId, }, }, - }) as ProviderAdapterV2Event; + }) as ProviderAdapter.ProviderAdapterV2Event; const earlyChild = ThreadId.make("thread:late-child:early"); const lateChild = ThreadId.make("thread:late-child:late"); @@ -246,7 +239,7 @@ it("does not route a superseded attempt through a reused provider thread", () => ...oldAttempt, attemptId: RunAttemptId.make("attempt:shared-runtime:restart:new"), }; - const oldTurnEvent: ProviderAdapterV2Event = { + const oldTurnEvent: ProviderAdapter.ProviderAdapterV2Event = { type: "provider_turn.updated", driver, threadId, @@ -299,33 +292,33 @@ it("routes only exact same-thread background items inherited from settled runs", type: "subagent", status: "running", }, - } as Extract; + } as Extract; const unrelatedRunItem = { ...inheritedRunning, turnItem: { ...inheritedRunning.turnItem, runId: RunId.make("run:inherited-background-routing:unrelated"), }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; const unlistedPriorItem = { ...inheritedRunning, turnItem: { ...inheritedRunning.turnItem, id: TurnItemId.make("turn-item:inherited-background-routing:unrelated"), }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; const unrelatedThreadItem = { ...inheritedRunning, turnItem: { ...inheritedRunning.turnItem, threadId: otherThreadId }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; const ordinaryItem = { ...inheritedRunning, turnItem: { ...inheritedRunning.turnItem, type: "reasoning" as const }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; const inheritedTerminal = { ...inheritedRunning, turnItem: { ...inheritedRunning.turnItem, status: "completed" as const }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; const [runningAccepted, afterRunning] = RunExecutionService.routeProviderEvent( inheritedRunning, @@ -515,7 +508,7 @@ it("does not carry interrupted or still-running child ownership into later attem startedAt: null, completedAt: null, }, - } satisfies ProviderAdapterV2Event; + } satisfies ProviderAdapter.ProviderAdapterV2Event; assert.isFalse(RunExecutionService.routeProviderEvent(lateChildNode, identity, state)[0]); }); @@ -550,7 +543,7 @@ it.effect("rechecks run ownership immediately before calling the provider", () = const session = { events: Stream.never, startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapterV2SessionRuntime; + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-start-guard"), @@ -597,14 +590,15 @@ it.effect("rechecks run ownership immediately before calling the provider", () = it.effect("passes the thread's MCP app context to the provider under a safe key", () => Effect.gen(function* () { const runExecution = yield* RunExecutionService.RunExecutionServiceV2; - const started = yield* Deferred.make(); + const started = yield* Deferred.make(); const threadId = ThreadId.make("thread:run-execution-app-context"); const providerInstanceId = ProviderInstanceId.make("codex"); const attemptId = RunAttemptId.make("attempt:run-execution-app-context"); const session = { events: Stream.never, - startTurn: (input: ProviderAdapterV2TurnInput) => Deferred.succeed(started, input), - } as unknown as ProviderAdapterV2SessionRuntime; + startTurn: (input: ProviderAdapter.ProviderAdapterV2TurnInput) => + Deferred.succeed(started, input), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; yield* runExecution.startRootRun({ commandId: CommandId.make("command:run-execution-app-context"), @@ -716,7 +710,7 @@ it.effect("fails the run when its ownership check cannot be read before calling session: { events: Stream.never, startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, rootNode: { id: NodeId.make("node:run-execution-start-guard-read"), @@ -821,7 +815,7 @@ it.effect( Effect.sync(() => { calls.push("compact"); }), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: RunId.make(`run:compact-routing:${index}`), threadId, @@ -888,7 +882,7 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => session: { events: Stream.never, startTurn: () => Ref.update(order, (entries) => [...entries, "start-turn"]), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: RunId.make("run:run-execution-mcp-liveness"), threadId, @@ -1000,7 +994,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => session: { events: Stream.never, startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, @@ -1137,7 +1131,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co session: { events: Stream.never, startTurn: () => Ref.update(providerStarts, (count) => count + 1), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, @@ -1288,7 +1282,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", ), ), ); - const events: ReadonlyArray = [ + const events: ReadonlyArray = [ { type: "app_thread.created", driver, @@ -1300,7 +1294,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", rootThreadId: threadId, }, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "provider_thread.updated", driver, @@ -1308,7 +1302,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", id: childProviderThreadId, appThreadId: childThreadId, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated", driver, @@ -1318,7 +1312,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", runId, status: "running", }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "provider_turn.updated", driver, @@ -1330,7 +1324,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", runAttemptId: null, status: "running", }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal", driver, @@ -1353,7 +1347,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", text: "Hello.", streaming: false, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "provider_turn.updated", driver, @@ -1365,7 +1359,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", runAttemptId: null, status: "completed", }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated", driver, @@ -1375,7 +1369,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", runId, status: "completed", }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, ]; yield* Effect.gen(function* () { @@ -1387,7 +1381,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", session: { events: Stream.fromIterable(events), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: runId, threadId, @@ -1788,7 +1782,7 @@ it.effect( ], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, rootTerminalEvent(ids, "completed"), { type: "provider_thread.updated", @@ -1799,12 +1793,12 @@ it.effect( pendingBackgroundTasks: [], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, ]), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -2000,7 +1994,7 @@ it.effect("drops late root provider-thread writes from a superseded attempt", () ], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, rootTerminalEvent(ids, "completed"), // Late snapshot after a replacement attempt claimed the same // run ordinal. Without attempt gating this would clobber it. @@ -2016,12 +2010,12 @@ it.effect("drops late root provider-thread writes from a superseded attempt", () ], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, ]), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -2197,7 +2191,7 @@ it.effect( ], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, // Late completion still writable (turn_item writes are not // ownership-gated); stream must stay open for it. backgroundTurnItemEvent(ids, "command_execution", "completed", 2), @@ -2205,7 +2199,7 @@ it.effect( close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -2371,12 +2365,12 @@ it.effect( ], updatedAt: now, }, - } as ProviderAdapterV2Event, + } as ProviderAdapter.ProviderAdapterV2Event, ]), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -2439,7 +2433,7 @@ it.effect( ); const providerInstanceId = ProviderInstanceId.make("codex"); const written = yield* Ref.make>([]); - const ingested = yield* Ref.make>([]); + const ingested = yield* Ref.make>([]); const ingestionDone = yield* Deferred.make(); const layerTest = RunExecutionService.layer.pipe( Layer.provide( @@ -2609,11 +2603,11 @@ it.effect( completedAt: runningChildNode.startedAt, }, }, - ] satisfies ReadonlyArray), + ] satisfies ReadonlyArray), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -2940,11 +2934,11 @@ it.effect( }, }, rootTerminalEvent(ids, "interrupted"), - ] satisfies ReadonlyArray), + ] satisfies ReadonlyArray), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -3439,7 +3433,7 @@ it.effect("refreshes pull requests after a provider stream exits with an error", shouldFinalizeRun: () => Effect.succeed(true), events: () => Stream.fail( - new ProviderAdapterEventStreamError({ + new ProviderAdapter.ProviderAdapterEventStreamError({ driver, providerSessionId: ProviderSessionId.make("session:exited"), cause: "provider process exited", @@ -3468,7 +3462,7 @@ it.effect("refreshes pull requests only once when startup failure closes its eve Deferred.await(ingestionStarted).pipe( Effect.andThen( Effect.fail( - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver, threadId: input.threadId, providerThreadId: input.providerThread.id, @@ -3506,8 +3500,11 @@ function captureRootRunTermination(input: { readonly seedOpenSubagent?: boolean; readonly events?: ( ids: BackgroundScenarioIds, - ) => Stream.Stream; - readonly startTurn?: ProviderAdapterV2SessionRuntime["startTurn"]; + ) => Stream.Stream< + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterV2Error + >; + readonly startTurn?: ProviderAdapter.ProviderAdapterV2SessionRuntime["startTurn"]; readonly refreshAfterTurn?: Effect.Effect; }) { return Effect.gen(function* () { @@ -3622,11 +3619,11 @@ function captureRootRunTermination(input: { ] : []), rootTerminalEvent(ids, "interrupted"), - ] satisfies ReadonlyArray), + ] satisfies ReadonlyArray), close: Deferred.succeed(ingestionDone, undefined), }), startTurn: input.startTurn ?? (() => Effect.void), - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, @@ -3716,7 +3713,9 @@ function backgroundScenarioIds(key: string): BackgroundScenarioIds { }; } -function childThreadCreatedEvent(ids: BackgroundScenarioIds): ProviderAdapterV2Event { +function childThreadCreatedEvent( + ids: BackgroundScenarioIds, +): ProviderAdapter.ProviderAdapterV2Event { return { type: "app_thread.created", driver, @@ -3728,14 +3727,14 @@ function childThreadCreatedEvent(ids: BackgroundScenarioIds): ProviderAdapterV2E rootThreadId: ids.threadId, }, }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; } function childBackgroundTurnItemEvent( ids: BackgroundScenarioIds, status: "running" | "completed" | "idle", ordinal: number, -): ProviderAdapterV2Event { +): ProviderAdapter.ProviderAdapterV2Event { return { type: "turn_item.updated", driver, @@ -3748,7 +3747,7 @@ function childBackgroundTurnItemEvent( type: "command_execution", status, }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; } function backgroundTurnItemEvent( @@ -3757,7 +3756,7 @@ function backgroundTurnItemEvent( status: "running" | "completed" | "idle", ordinal: number, itemId?: TurnItemId, -): ProviderAdapterV2Event { +): ProviderAdapter.ProviderAdapterV2Event { return { type: "turn_item.updated", driver, @@ -3770,7 +3769,7 @@ function backgroundTurnItemEvent( type, status, }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; } function backgroundTurnItemEventForRun( @@ -3779,7 +3778,7 @@ function backgroundTurnItemEventForRun( type: "command_execution" | "dynamic_tool" | "subagent", status: "running" | "completed" | "idle", ordinal: number, -): ProviderAdapterV2Event { +): ProviderAdapter.ProviderAdapterV2Event { const event = backgroundTurnItemEvent(ids, type, status, ordinal); if (event.type !== "turn_item.updated") { return event; @@ -3790,7 +3789,7 @@ function backgroundTurnItemEventForRun( function subagentEvent( ids: BackgroundScenarioIds, status: "running" | "completed" | "idle", -): ProviderAdapterV2Event { +): ProviderAdapter.ProviderAdapterV2Event { return { type: "subagent.updated", driver, @@ -3800,7 +3799,7 @@ function subagentEvent( runId: ids.runId, status, }, - } as ProviderAdapterV2Event; + } as ProviderAdapter.ProviderAdapterV2Event; } function makeRunOwnedSubagentFixture(input: { @@ -3977,7 +3976,7 @@ function makeRunOwnedSubagentChildNodeFixture(input: { function rootTerminalEvent( ids: BackgroundScenarioIds, status: "completed" | "interrupted" | "cancelled" | "failed", -): ProviderAdapterV2Event { +): ProviderAdapter.ProviderAdapterV2Event { const common = { type: "turn.terminal" as const, driver, @@ -4003,7 +4002,7 @@ function rootTerminalEvent( function runBackgroundItemScenario( key: string, - makeEvents: (ids: BackgroundScenarioIds) => ReadonlyArray, + makeEvents: (ids: BackgroundScenarioIds) => ReadonlyArray, options?: { readonly keepEventStreamOpen?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< @@ -4082,7 +4081,7 @@ function runBackgroundItemScenario( }; }), startTurn: () => Effect.void, - } as unknown as ProviderAdapterV2SessionRuntime, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, run: { id: ids.runId, threadId: ids.threadId, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 111aced2865e..a316ce0ccd62 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -41,13 +41,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import type { - ProviderAdapterV2Event, - ProviderAdapterV2RuntimePolicy, - ProviderAdapterV2SessionRuntime, - ProviderAdapterV2TurnMessage, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import { ProviderAdapterTurnStartError } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import type { ProjectionStoreV2Error } from "./ProjectionStore.ts"; import { @@ -79,7 +73,10 @@ export interface InheritedBackgroundTurnItemRoute { readonly runId: OrchestrationV2Run["id"]; } -type ProviderTerminalEvent = Extract; +type ProviderTerminalEvent = Extract< + ProviderAdapter.ProviderAdapterV2Event, + { readonly type: "turn.terminal" } +>; function isTerminalProviderTurnStatus(status: OrchestrationV2ProviderTurn["status"]): boolean { return ( @@ -351,7 +348,7 @@ export function makeProviderEventRoutingState(input: { } export function routeProviderEvent( - event: ProviderAdapterV2Event, + event: ProviderAdapter.ProviderAdapterV2Event, input: ProviderEventRouteIdentity, state: ProviderEventRoutingState, ): readonly [boolean, ProviderEventRoutingState] { @@ -503,7 +500,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly commandId: CommandId; readonly appThread: OrchestrationV2AppThread; readonly providerSessionId: ProviderSessionId; - readonly session: ProviderAdapterV2SessionRuntime; + readonly session: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly run: OrchestrationV2Run; readonly rootNode: OrchestrationV2ExecutionNode; readonly checkpointScope: OrchestrationV2CheckpointScope; @@ -521,9 +518,9 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldStartProviderTurn?: () => Effect.Effect; readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; - readonly message: ProviderAdapterV2TurnMessage; + readonly message: ProviderAdapter.ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; } export interface RunExecutionServiceV2Shape { @@ -738,7 +735,6 @@ export const layer: Layer.Layer< providerInstanceId: input.run.providerInstanceId, occurredAt: completedAt, payload: makeProviderFailureTurnItem({ - idAllocator, driver: input.terminal.driver, threadId: input.run.threadId, runId: input.run.id, @@ -1006,7 +1002,10 @@ export const layer: Layer.Layer< } yield* Ref.set(rootRunFinalized, true); }); - const trackChildLifecycle = (event: ProviderAdapterV2Event, deliverable: boolean) => + const trackChildLifecycle = ( + event: ProviderAdapter.ProviderAdapterV2Event, + deliverable: boolean, + ) => Effect.gen(function* () { const routing = yield* Ref.get(eventRouting); if (event.type === "provider_turn.updated") { @@ -1403,7 +1402,7 @@ export const layer: Layer.Layer< const startTurn = compact ? (input.session.compactThread?.(turnInput) ?? Effect.fail( - new ProviderAdapterTurnStartError({ + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: input.session.driver, threadId: input.run.threadId, providerThreadId: input.providerThread.id, @@ -1471,7 +1470,7 @@ export const layer: Layer.Layer< ); export function makeInterruptResultTurnItem(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly run: OrchestrationV2Run; readonly rootNode: OrchestrationV2ExecutionNode; readonly providerThread: OrchestrationV2ProviderThread; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index a512b7dcabe9..6d6fcf82d64d 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -12,10 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, -} from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectStore from "./ProjectStore.ts"; /** @@ -52,7 +49,7 @@ export interface RuntimePolicyV2Shape { readonly resolve: (input: { readonly thread: OrchestrationV2AppThread; readonly modelSelection: ModelSelection; - }) => Effect.Effect; + }) => Effect.Effect; } export class RuntimePolicyV2 extends Context.Service()( @@ -128,7 +125,7 @@ export const layerFromProjectStore: Layer.Layer< }), ), )); - return ProviderAdapterV2RuntimePolicy.make({ + return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: providerRuntimeMode(input.thread.runtimeMode, supportedRuntimeModes), interactionMode: input.thread.interactionMode, cwd, @@ -149,7 +146,7 @@ export function layerWithOverride( resolve: (input) => base.resolve(input).pipe( Effect.map((policy) => - ProviderAdapterV2RuntimePolicy.make({ + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ ...policy, ...(override.cwd === undefined ? {} : { cwd: override.cwd }), ...(override.approvalPolicy === undefined diff --git a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts index eb4670258101..11dedc6ab474 100644 --- a/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts +++ b/apps/server/src/orchestration-v2/RuntimeRequestService.test.ts @@ -12,7 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import type { ProviderAdapterV2RuntimeRequestResponseInput } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeRequestService from "./RuntimeRequestService.ts"; @@ -69,7 +69,7 @@ it.effect("forwards orchestrator-resolved runtime requests to the live adapter", const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-resolved"); const requestId = RuntimeRequestId.make("request-resolved"); const respondToRuntimeRequest = vi.fn( - (_input: ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, + (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, ); const getSession = vi.fn(() => Effect.succeed( @@ -133,7 +133,7 @@ it.effect("rejects expired runtime requests before invoking the live adapter", ( const providerSessionId = ProviderSessionId.make("provider-session-runtime-request-expired"); const requestId = RuntimeRequestId.make("request-expired"); const respondToRuntimeRequest = vi.fn( - (_input: ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, + (_input: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) => Effect.void, ); const getSession = vi.fn(() => Effect.succeed( diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 425336ac29e0..958fb89cc8e4 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -28,15 +28,10 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import { - ProviderAdapterOpenSessionError, - type ProviderAdapterV2Event, - type ProviderAdapterV2Shape, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const driver = ProviderDriverKind.make("codex"); const providerInstanceId = ProviderInstanceId.make("codex-restart-test"); @@ -69,7 +64,7 @@ const exclusiveCapabilities: OrchestrationV2ProviderCapabilities = { }; interface ActiveTurn { - readonly input: ProviderAdapterV2TurnInput; + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; readonly providerTurnId: ProviderTurnId; } @@ -92,7 +87,7 @@ function makeRestartAdapter( state: Ref.Ref, sessionCapabilities: OrchestrationV2ProviderCapabilities = pooledCapabilities, providerInstanceId = initialSelection.instanceId, -): ProviderAdapterV2Shape { +): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: providerInstanceId, driver, @@ -125,14 +120,14 @@ function makeRestartAdapter( ] as const; }); if (failThisOpen) { - return yield* new ProviderAdapterOpenSessionError({ + return yield* new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: sessionInput.providerSessionId, cause: "simulated replacement open failure", }); } - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -290,7 +285,9 @@ function makeRestartAdapter( }; } -function makeCompletingHandoffAdapter(startCount: Ref.Ref): ProviderAdapterV2Shape { +function makeCompletingHandoffAdapter( + startCount: Ref.Ref, +): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: handoffProviderInstanceId, driver: handoffDriver, @@ -298,7 +295,7 @@ function makeCompletingHandoffAdapter(startCount: Ref.Ref): ProviderAdap planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (sessionInput) => Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const now = yield* DateTime.now; return { instanceId: handoffProviderInstanceId, @@ -865,7 +862,7 @@ it.live.each(["active", "idle", "selection-command", "pooled", "separate-home"] }), })), ), - } satisfies ProviderAdapterV2Shape; + } satisfies ProviderAdapter.ProviderAdapterV2["Service"]; }); const layerRegistry = Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistryV2, { get: (instanceId) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 8c28cbc03ce9..730b72feea6b 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -23,15 +23,10 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectWorker from "./EffectWorker.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import { - ProviderAdapterSteerRunError, - type ProviderAdapterV2Event, - type ProviderAdapterV2Shape, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); @@ -67,8 +62,8 @@ it.effect.each( Effect.scoped( Effect.gen(function* () { const cwd = yield* checkpointWorkspace(`steering-completion-${timing.replaceAll(" ", "-")}`); - const events = yield* Queue.unbounded(); - const started: ProviderAdapterV2TurnInput[] = []; + const events = yield* Queue.unbounded(); + const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steerEntered = yield* Deferred.make(); const rejectSteer = yield* Deferred.make(); let steerCalls = 0; @@ -80,7 +75,7 @@ it.effect.each( activeSteeringInterruptsTools: timing === "with interrupting native steering", }, }; - const adapter: ProviderAdapterV2Shape = { + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { instanceId, driver, getCapabilities: () => Effect.succeed(capabilities), @@ -153,7 +148,7 @@ it.effect.each( if (timing === "after delivery") return; yield* Deferred.succeed(steerEntered, undefined); yield* Deferred.await(rejectSteer); - return yield* new ProviderAdapterSteerRunError({ + return yield* new ProviderAdapter.ProviderAdapterSteerRunError({ driver, providerThreadId: turn.providerThread.id, providerTurnId: turn.providerTurnId, @@ -424,8 +419,8 @@ const composerSelection = { const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* (name: string) { const cwd = yield* checkpointWorkspace(name); - const events = yield* Queue.unbounded(); - const started: ProviderAdapterV2TurnInput[] = []; + const events = yield* Queue.unbounded(); + const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steered: string[] = []; const capabilities = { ...CodexProviderCapabilitiesV2, @@ -435,7 +430,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* supportsSteeringByInterruptRestart: false, }, }; - const adapter: ProviderAdapterV2Shape = { + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { instanceId, driver, getCapabilities: () => Effect.succeed(capabilities), diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index fca9c05ae3e7..1b326cc21c44 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -7,7 +7,7 @@ import type * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import type { PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; -import type { IdAllocatorV2, IdAllocatorV2Error } from "@t3tools/provider-core/server/IdAllocator"; +import type * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; export interface ThreadDeletionPlan { readonly events: ReadonlyArray; @@ -23,8 +23,8 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") >; readonly attachmentIds: ReadonlyArray; readonly now: DateTime.Utc; - readonly idAllocator: IdAllocatorV2["Service"]; -}): Effect.fn.Return { + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; +}): Effect.fn.Return { const { command, projection, now, idAllocator } = input; const events: Array = []; const effects: Array = []; diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 77e95378c8bc..a13277418550 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -21,15 +21,15 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts"; import * as EventSink from "./EventSink.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const forkCases = (["codex", "claudeAgent"] as const).flatMap((driverName) => { const driver = ProviderDriverKind.make(driverName); const instanceId = ProviderInstanceId.make(driver); const modelSelection = { instanceId, model: "test-model" }; - const adapter: ProviderAdapterV2Shape = { + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { instanceId, driver, getCapabilities: () => diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index bfbd078e00c5..dfa02f4f514b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -54,12 +54,12 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const projectId = ProjectId.make("project:launch-test"); const otherProjectId = ProjectId.make("project:launch-other"); @@ -94,7 +94,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in launch tests"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; interface HarnessOptions { readonly managedFolders?: Layer.Layer; diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 43a77a50e75c..6c5874cb75f2 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -24,10 +24,10 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const instanceId = ProviderInstanceId.make("codex"); const modelSelection = { instanceId, model: "gpt-5.1-codex" }; @@ -37,7 +37,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("Runs here never reach a provider"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const layerDatabase = SqlitePersistence.layerMemory; // No effect worker: runs stay unstarted, so Stop ends them without a provider. const layerTest = ThreadManagementService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 392dc3fdaee7..06f7aa2488c8 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -22,11 +22,11 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; -import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const projectId = ProjectId.make("project:title-regeneration"); const modelSelection = { @@ -40,7 +40,7 @@ const adapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), openSession: () => Effect.die("provider execution is disabled in title regeneration tests"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; function makeHarness( options: { diff --git a/apps/server/src/orchestration-v2/assistantStreaming.test.ts b/apps/server/src/orchestration-v2/assistantStreaming.test.ts index 2580936b0dfb..8e36a5abd75e 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.test.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { MessageId, NodeId, ProviderDriverKind, ThreadId, TurnItemId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { makeAssistantStreamingFilter, splitBufferedAssistantText } from "./assistantStreaming.ts"; -import type { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -const message = (text: string, streaming = true): ProviderAdapterV2Event => ({ +const message = (text: string, streaming = true): ProviderAdapter.ProviderAdapterV2Event => ({ type: "message.updated", driver: ProviderDriverKind.make("codex"), message: { @@ -27,7 +27,7 @@ const turnItem = ( text: string, streaming = true, type: "assistant_message" | "reasoning" = "assistant_message", -): ProviderAdapterV2Event => ({ +): ProviderAdapter.ProviderAdapterV2Event => ({ type: "turn_item.updated", driver: ProviderDriverKind.make("codex"), turnItem: { @@ -108,7 +108,7 @@ describe("V2 assistant streaming", () => { "suppresses running assistant nodes in %s mode while delivering tool and completed nodes", (mode) => { const filter = makeAssistantStreamingFilter(mode); - const running: Extract = { + const running: Extract = { type: "node.updated", driver: ProviderDriverKind.make("codex"), node: { diff --git a/apps/server/src/orchestration-v2/assistantStreaming.ts b/apps/server/src/orchestration-v2/assistantStreaming.ts index 4c70dbbfc35e..d17b73daebfd 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.ts @@ -1,5 +1,5 @@ import type { ResponseStreamingMode } from "@t3tools/contracts"; -import type { ProviderAdapterV2Event } from "@t3tools/provider-core/server/ProviderAdapter"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; // An opening fence may sit at any indentation, since fences inside list // items are indented past the marker. A closing fence may be indented at most @@ -91,7 +91,10 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: /** Buffer assistant messages and reasoning alike, keeping unfinished markdown out of the read model. */ export function makeAssistantStreamingFilter(mode: ResponseStreamingMode) { const delivered = new Map(); - return (event: ProviderAdapterV2Event, now: number): ProviderAdapterV2Event | null => { + return ( + event: ProviderAdapter.ProviderAdapterV2Event, + now: number, + ): ProviderAdapter.ProviderAdapterV2Event | null => { if ( event.type === "node.updated" && event.node.kind === "assistant_message" && diff --git a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts index 803bc7ce0902..9ae1e74b9410 100644 --- a/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts +++ b/apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts @@ -44,14 +44,10 @@ import * as LegacyV1ThreadImporter from "./LegacyV1ThreadImporter.ts"; import * as Orchestrator from "../Orchestrator.ts"; import * as ProjectionMaintenance from "../ProjectionMaintenance.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; -import { - ProviderAdapterProtocolError, - type ProviderAdapterV2Event, - type ProviderAdapterV2Shape, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const PROJECT_ID = "project:cutover"; const ACTIVE_THREAD = "thread:cutover:active"; @@ -422,7 +418,7 @@ interface CapturedTurn { } const unimplemented = (detail: string) => - Effect.fail(new ProviderAdapterProtocolError({ driver, detail })); + Effect.fail(new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail })); const makeCodexAdapter = (capturedTurns: Ref.Ref>) => ({ @@ -432,7 +428,7 @@ const makeCodexAdapter = (capturedTurns: Ref.Ref>) = planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (sessionInput) => Effect.gen(function* () { - const events = yield* PubSub.unbounded(); + const events = yield* PubSub.unbounded(); const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -551,7 +547,7 @@ const makeCodexAdapter = (capturedTurns: Ref.Ref>) = failure: null, threadDisposition: "reusable", }, - ] satisfies ReadonlyArray); + ] satisfies ReadonlyArray); }), steerTurn: () => Effect.void, interruptTurn: () => Effect.void, @@ -561,7 +557,7 @@ const makeCodexAdapter = (capturedTurns: Ref.Ref>) = forkThread: () => unimplemented("forkThread unused in cutover test"), }; }), - }) satisfies ProviderAdapterV2Shape; + }) satisfies ProviderAdapter.ProviderAdapterV2["Service"]; const waitForIdle = Effect.fn("LegacyV1Cutover.waitForIdle")(function* (threadId: ThreadId) { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index aed7f73e6c0f..f28cbae65970 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -67,16 +67,13 @@ import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import type { - ProviderAdapterV2SessionRuntime, - ProviderAdapterV2Shape, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as RuntimeLayer from "./runtimeLayer.ts"; import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerPlatformTest = Layer.merge( NodeServices.layer, @@ -119,7 +116,7 @@ const orchestrationAdapter = { getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: () => Effect.die("sessions are not used by lifecycle tests"), -} as ProviderAdapterV2Shape; +} as ProviderAdapter.ProviderAdapterV2["Service"]; const providerInstance = { instanceId: modelSelection.instanceId, driverKind: driver, @@ -928,7 +925,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { const sessionSpy = vi .spyOn(sessions, "get") .mockReturnValue( - Effect.succeed(Option.some({ providerSession } as ProviderAdapterV2SessionRuntime)), + Effect.succeed( + Option.some({ providerSession } as ProviderAdapter.ProviderAdapterV2SessionRuntime), + ), ); yield* Effect.addFinalizer(() => Effect.sync(() => sessionSpy.mockRestore())); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 5d736f1564be..2442a4fa010c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -30,7 +30,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectStore from "./ProjectStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "./ProviderContinuationService.ts"; import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index d24ae5b624d9..318bd28dd9bd 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -35,7 +35,7 @@ import * as ProjectionStore from "../ProjectionStore.ts"; import * as ProjectStore from "../ProjectStore.ts"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index e7633781036b..9bde775d0d4e 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -52,14 +52,6 @@ import * as EffectWorker from "../EffectWorker.ts"; import * as EffectOutbox from "../EffectOutbox.ts"; import * as ProjectionMaintenance from "../ProjectionMaintenance.ts"; import * as ProjectionStore from "../ProjectionStore.ts"; -import { - type ProviderAdapterV2Event, - type ProviderAdapterV2HistoricalContext, - ProviderAdapterProtocolError, - ProviderAdapterTurnStartError, - type ProviderAdapterV2Shape, - type ProviderAdapterV2SessionRuntime, -} from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { @@ -70,6 +62,7 @@ import { } from "./fixtures/shared.ts"; import * as ProviderReplayHarness from "./ProviderReplayHarness.ts"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -95,7 +88,7 @@ interface CapturedTurn { } function unimplemented(driver: ProviderDriverKind, detail: string) { - return Effect.fail(new ProviderAdapterProtocolError({ driver, detail })); + return Effect.fail(new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail })); } function makeTestAdapter(input: { @@ -116,7 +109,7 @@ function makeTestAdapter(input: { readonly failResumeOnce?: Ref.Ref; readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"]; readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined; - readonly canReuseContextUsage?: ProviderAdapterV2SessionRuntime["canReuseContextUsage"]; + readonly canReuseContextUsage?: ProviderAdapter.ProviderAdapterV2SessionRuntime["canReuseContextUsage"]; readonly tokenUsageByRunOrdinal?: Readonly< Record> >; @@ -125,7 +118,7 @@ function makeTestAdapter(input: { readonly holdRunOrdinal?: number; readonly holdFirstTurn?: Deferred.Deferred; readonly releaseFirstTurn?: Deferred.Deferred; -}): ProviderAdapterV2Shape { +}): ProviderAdapter.ProviderAdapterV2["Service"] { return { instanceId: input.instanceId, driver: input.driver, @@ -133,7 +126,7 @@ function makeTestAdapter(input: { planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (sessionInput) => Effect.gen(function* () { - const events = yield* PubSub.unbounded(); + const events = yield* PubSub.unbounded(); const now = yield* DateTime.now; const providerSession: OrchestrationV2ProviderSession = { id: sessionInput.providerSessionId, @@ -148,7 +141,7 @@ function makeTestAdapter(input: { lastError: null, }; - const runtime: ProviderAdapterV2SessionRuntime = { + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: input.instanceId, driver: input.driver, providerSessionId: sessionInput.providerSessionId, @@ -204,7 +197,7 @@ function makeTestAdapter(input: { ...(input.injectedHistory === undefined ? {} : { - injectHistory: (history: ProviderAdapterV2HistoricalContext) => + injectHistory: (history: ProviderAdapter.ProviderAdapterV2HistoricalContext) => Ref.update(input.injectedHistory!, (current) => [ ...current, ...historyResponseItems(history.messages, history.context), @@ -236,7 +229,7 @@ function makeTestAdapter(input: { input.refuseStarts !== undefined && (yield* Ref.getAndUpdate(input.refuseStarts, (left) => Math.max(0, left - 1))) > 0 ) - return yield* new ProviderAdapterTurnStartError({ + return yield* new ProviderAdapter.ProviderAdapterTurnStartError({ driver: input.driver, threadId: turnInput.threadId, providerThreadId: turnInput.providerThread.id, @@ -280,7 +273,7 @@ function makeTestAdapter(input: { input.responseByThreadId?.[turnInput.threadId]?.[turnInput.runOrdinal] ?? input.responseByRunOrdinal[turnInput.runOrdinal] ?? `${input.driver} response for run ${turnInput.runOrdinal}`; - const providerEvents: ReadonlyArray = [ + const providerEvents: ReadonlyArray = [ { type: "provider_turn.updated", driver: input.driver, diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index b6845a029721..2eda1e31533d 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -19,8 +19,8 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; -import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/ProviderAuthFlow"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow"; +import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/providerAuthFlow"; import { HttpClient, HttpClientRequest } from "effect/http"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 33aa39f5598b..7ae333fd1e45 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -29,7 +29,7 @@ import { ANTIGRAVITY_AUTH_STDOUT_PREFIX, resolveAntigravityInstanceDirectories, } from "../antigravityAuthSupport.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ModelManifest from "../ModelManifest.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { AntigravityDriver } from "./AntigravityDriver.ts"; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index d8710e901a4d..3bac2a48953e 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -18,7 +18,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { AcpError } from "effect-acp/errors"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { isAntigravityTextGenerationAvailable, makeAntigravityTextGeneration, @@ -46,12 +46,12 @@ import { removeAntigravitySessionFiles, } from "../acp/AntigravitySessionFiles.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; import { ProviderDriverError } from "../Errors.ts"; import { makeAntigravityProvider } from "../AntigravityProvider.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { defaultProviderContinuationIdentity, @@ -68,7 +68,7 @@ const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); export type AntigravityDriverEnv = | AntigravityInstallation.AntigravityInstallation - | ProviderHost + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem @@ -89,7 +89,7 @@ export const AntigravityDriver: ProviderDriver = { const { cwd } = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; - const host = yield* ProviderHost; const catalogService = yield* ModelCatalog.ModelCatalog; const modelCatalog = catalogService .current(DRIVER_KIND) @@ -237,7 +236,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 72340b7471cf..296cc27eb605 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -26,7 +26,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ModelManifest from "../ModelManifest.ts"; import { createProviderVersionAdvisory, @@ -36,7 +36,7 @@ import { import { CodexDriver } from "./CodexDriver.ts"; import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; import * as ProviderHostLive from "../ProviderHostLive.ts"; @@ -214,7 +214,7 @@ it.layer(layerTest)("CodexDriver", (it) => { threadId, providerSessionId: ProviderSessionId.make("managed-account-session"), modelSelection: { instanceId, model: "gpt-5.4" }, - runtimePolicy: ProviderAdapterV2RuntimePolicy.make({ + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: serverConfig.stateDir, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index a2e580330c3c..0759be9b868d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -31,10 +31,10 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { createCodexAdapterV2, type CodexAdapterV2DriverEnv, @@ -109,7 +109,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { */ export type CodexDriverEnv = | CodexAdapterV2DriverEnv - | ProviderHost + | ProviderHost.ProviderHost | ChildProcessSpawner.ChildProcessSpawner | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto @@ -147,7 +147,6 @@ export const CodexDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; - const host = yield* ProviderHost; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); const processEnv = mergeProviderInstanceEnvironment(environment); @@ -231,7 +230,7 @@ export const CodexDriver: ProviderDriver = { ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 32d88e644879..a7181352b288 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -4,7 +4,7 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { chatGptModels } from "../CodexChatGptModels.ts"; import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; import { ProviderDriverError } from "../Errors.ts"; @@ -31,7 +31,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const { instanceId, enabled, displayName, accentColor, config } = input; const http = yield* HttpClient.HttpClient; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const host = yield* ProviderHost; + const host = yield* ProviderHost.ProviderHost; const runtime = yield* makeCodexManagedRuntime({ instanceId, enabled, diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 1ec5c46d9324..f8cbca0c1316 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -9,7 +9,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ThreadId } from "@t3tools/contracts"; -import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { errorTag } from "@t3tools/shared/observability"; import * as Clock from "effect/Clock"; @@ -21,7 +21,7 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { toSafeThreadAttachmentSegment } from "../attachmentStore.ts"; -import type { ResourceAttribution } from "../resourceTelemetry/ResourceAttribution.ts"; +import type * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; const MEBIBYTE = 1024 * 1024; const DAY_MS = 24 * 60 * 60 * 1_000; @@ -64,11 +64,9 @@ const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk export type EventNdjsonStream = "native" | "canonical" | "orchestration"; -export type { EventNdjsonLogger }; - export interface EventNdjsonLogStore { readonly filePath: string; - readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; + readonly logger: (stream: EventNdjsonStream) => ProviderEventLoggers.EventNdjsonLogger; readonly close: () => Effect.Effect; } @@ -81,7 +79,7 @@ export interface EventNdjsonLogStoreOptions { readonly retentionCheckIntervalMs?: number; readonly maxBufferedBytes?: number; readonly maxBufferedRecords?: number; - readonly attribution?: ResourceAttribution["Service"]; + readonly attribution?: ResourceAttribution.ResourceAttribution["Service"]; } export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { @@ -127,7 +125,7 @@ interface ResolvedOptions { readonly retentionCheckIntervalMs: number; readonly maxBufferedBytes: number; readonly maxBufferedRecords: number; - readonly attribution: ResourceAttribution["Service"] | undefined; + readonly attribution: ResourceAttribution.ResourceAttribution["Service"] | undefined; } export interface PendingRecord { @@ -731,8 +729,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( yield* Scope.close(timerScope, Exit.void); }); - const loggerViews = new Map(); - const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { + const loggerViews = new Map(); + const logger = (stream: EventNdjsonStream): ProviderEventLoggers.EventNdjsonLogger => { const existing = loggerViews.get(stream); if (existing) return existing; @@ -778,7 +776,11 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( } }); - const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; + const view = { + filePath, + write, + close: () => Effect.void, + } satisfies ProviderEventLoggers.EventNdjsonLogger; loggerViews.set(stream, view); return view; }; @@ -789,7 +791,7 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( filePath: string, options: EventNdjsonLoggerOptions, -): Effect.fn.Return { +): Effect.fn.Return { const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( Effect.catch((error) => logWarning(error.message, { error }).pipe( diff --git a/apps/server/src/provider/ProviderEventLoggers.ts b/apps/server/src/provider/ProviderEventLoggers.ts deleted file mode 100644 index b31acbef952b..000000000000 --- a/apps/server/src/provider/ProviderEventLoggers.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * ProviderEventLoggers — single observability service that owns the shared - * provider event log store and exposes its two runtime views: - * - * - `native` — provider-protocol events as the SDK emits them, written - * from inside each `Adapter` factory. - * - `canonical` — runtime events after `ProviderService` has normalized - * them onto `ProviderRuntimeEvent`. - * - * Why a service tag and not constructor options? - * - * - Adapters are now constructed *inside* drivers (`Driver.create()`), - * not at the boot Layer. There is no longer a single `makeAdapterLive(options)` - * call site where we can hand an `EventNdjsonLogger` in by hand. - * - Multiple driver instances per kind (`codex_personal`, `codex_work`) - * must share one underlying log store — opening N writers against the - * same rotating file would race the rotation logic. Owning the loggers on - * a single tag keeps that invariant intact. - * - Tests can swap one (or both) loggers with in-memory recorders by - * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of - * juggling per-Layer option threading. - * - * Both fields are optional because observability must not prevent startup. - * - * @module provider/ProviderEventLoggers - */ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as ServerConfig from "../config.ts"; -import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; -import { - NoOpProviderEventLoggers, - ProviderEventLoggers, -} from "@t3tools/provider-core/server/ProviderEventLoggers"; -import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; - -export { NoOpProviderEventLoggers, ProviderEventLoggers }; - -/** - * Builds both stream views over one shared store. Setup failures are logged - * and downgraded to the no-op service so diagnostics never block startup. - * - * @public Service construction is part of the canonical Effect module API. - */ -export const make = Effect.gen(function* () { - const { providerEventLogPath } = yield* ServerConfig.ServerConfig; - const attribution = yield* ResourceAttribution.ResourceAttribution; - const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { - attribution, - }).pipe( - Effect.catch((error) => - Effect.logWarning(error.message, { error }).pipe( - Effect.annotateLogs({ scope: "provider-observability" }), - Effect.as(undefined), - ), - ), - ); - - if (!store) { - return ProviderEventLoggers.of(NoOpProviderEventLoggers); - } - - yield* Effect.addFinalizer(() => store.close()); - return ProviderEventLoggers.of({ - native: store.logger("native"), - canonical: store.logger("canonical"), - }); -}); - -export const layer = Layer.effect(ProviderEventLoggers, make); diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 3a924b39bd66..14d08de50ee9 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -1,10 +1,10 @@ /** - * The server's implementation of `ProviderHost`, the only server surface + * The server's implementation of `ProviderHost.ProviderHost`, the only server surface * provider drivers and adapters may use. * * @module provider/ProviderHostLive */ -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -18,14 +18,14 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; export const layer = Layer.effect( - ProviderHost, + ProviderHost.ProviderHost, Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - return ProviderHost.of({ + return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, baseDir: config.baseDir, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 933751bdd39b..81208e54091e 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -61,7 +61,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 6048f48636ea..822b69bcb817 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -40,7 +40,7 @@ import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMuta import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as ProviderHostLive from "./ProviderHostLive.ts"; -import type { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import type * as ServerConfig from "../config.ts"; @@ -49,10 +49,10 @@ type ProviderInstanceRegistryHydrationEnv = BuiltInDriversEnv, | ProviderOrchestrationAdapterInfrastructure.ProviderOrchestrationAdapterInfrastructure | AcpRegistrySupport.AcpRegistryCatalog - | ProviderHost + | ProviderHost.ProviderHost > | Settings.ServerSettingsService - // Requirements of the `ProviderHost` the drivers receive. + // Requirements of the `ProviderHost.ProviderHost` the drivers receive. | BackgroundPolicy.BackgroundPolicy | ServerConfig.ServerConfig; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 2cbb70fbbdec..3dea8ca677ed 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -4,7 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; export type ProviderOrchestrationAdapterInfrastructure = | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8653bd32ebd2..f0ed96254414 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -47,7 +47,7 @@ import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; -import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettingsModule from "../serverSettings.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 79fb63afb64f..ab93e814e700 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,4 @@ +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as Clock from "effect/Clock"; import * as Random from "effect/Random"; @@ -42,7 +43,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; -import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; +import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; @@ -193,6 +194,30 @@ const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(layerResourceAttribution), ); +// One rotating store behind both logger views, so driver instances never race +// its rotation. Diagnostics must not block startup: a store that cannot open +// degrades to the no-op loggers. +const layerProviderEventLoggers = Layer.effect( + ProviderEventLoggers.ProviderEventLoggers, + Effect.gen(function* () { + const { providerEventLogPath } = yield* ServerConfig.ServerConfig; + const attribution = yield* ResourceAttribution.ResourceAttribution; + const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { + attribution, + }).pipe( + Effect.catch((error) => + Effect.logWarning(error.message, { error }).pipe( + Effect.annotateLogs({ scope: "provider-observability" }), + Effect.as(undefined), + ), + ), + ); + if (!store) return ProviderEventLoggers.NoOpProviderEventLoggers; + yield* Effect.addFinalizer(() => store.close()); + return { native: store.logger("native"), canonical: store.logger("canonical") }; + }), +); + const layerPtyAdapter = NodePtyAdapter.layer; const layerServerSettings = ServerSettings.layer.pipe( @@ -599,17 +624,14 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( // Search, prepare, status inspection, and turn launch share one registry // cache so every client and provider instance sees the same prepared agents. Layer.provideMerge(AcpRegistrySupport.layerFromHost.pipe(Layer.provide(ProviderHostLive.layer))), - // Shared native/canonical NDJSON writers used by both the per-instance - // V2 drivers and the orchestration runtime. Provide resource attribution so - // the rewritten telemetry pipeline can account for logical NDJSON writes. - // Provided once at the runtime level so every consumer sees the same - // logger instances. + // Provider event loggers are shared by every driver instance and the + // orchestration runtime, so they are provided once here. // `ModelManifest.layer` is the model manifest, refreshed from the repo's // `model-manifest.json` on `main`. Drivers read their entry through the // `ModelCatalog` port it provides. Layer.provideMerge( Layer.mergeAll( - ProviderEventLoggers.layer, + layerProviderEventLoggers, ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), ResetCreditCoordinator.layer, ), diff --git a/packages/provider-acp-registry/src/server/auth.ts b/packages/provider-acp-registry/src/server/auth.ts index 7189d5a4fdec..72993a7a5f89 100644 --- a/packages/provider-acp-registry/src/server/auth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -16,7 +16,7 @@ import { ChildProcessSpawner } from "effect/process"; import type * as AcpSchema from "effect-acp/compat"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; -import * as ProviderAuthFlow from "@t3tools/provider-core/server/ProviderAuthFlow"; +import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow"; import { normalizeAcpRegistryAuthMethods, normalizeAcpRegistryWebUrl } from "./probe.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index 17ca9ea3ddcb..0e699bee5bf8 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/process"; -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { AcpRegistryAdapterV2Driver, type AcpRegistryAdapterV2DriverEnv } from "./adapter.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; @@ -455,7 +455,7 @@ export const checkAcpRegistryProviderReadiness = Effect.fn( : snapshot; }); -export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost; +export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost.ProviderHost; /** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */ export const AcpRegistryDriver: ProviderDriver = { @@ -485,7 +485,7 @@ export const AcpRegistryDriver: ProviderDriver >({ diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 65c168314fd1..220521019818 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -95,7 +95,7 @@ import { } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { type BackgroundWork, type BackgroundWorkReport, @@ -499,7 +499,9 @@ export interface AcpAdapterV2Options { * / history-only behavior for null-activeTurn updates. */ readonly continuationRequests?: { - readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; + readonly offer: ( + request: ProviderContinuationRequests.ProviderContinuationRequest, + ) => Effect.Effect; }; readonly testHooks?: { /** @@ -743,7 +745,7 @@ function nativeThreadId( function makeProviderThread(input: { readonly driver: ProviderDriverKind; readonly providerInstanceId: ProviderInstanceId; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; readonly nativeThreadId: string; @@ -2226,7 +2228,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( type: "turn_item.updated", driver, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver, threadId: context.input.threadId, runId: context.input.runId, diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index fe4b25b981bc..2b7b653b6baa 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -23,10 +23,6 @@ "types": "./src/server/collectStreamText.ts", "import": "./src/server/collectStreamText.ts" }, - "./server/continuationRequests": { - "types": "./src/server/continuationRequests.ts", - "import": "./src/server/continuationRequests.ts" - }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" @@ -103,9 +99,13 @@ "types": "./src/server/ProviderAdapter.ts", "import": "./src/server/ProviderAdapter.ts" }, - "./server/ProviderAuthFlow": { - "types": "./src/server/ProviderAuthFlow.ts", - "import": "./src/server/ProviderAuthFlow.ts" + "./server/providerAuthFlow": { + "types": "./src/server/providerAuthFlow.ts", + "import": "./src/server/providerAuthFlow.ts" + }, + "./server/ProviderContinuationRequests": { + "types": "./src/server/ProviderContinuationRequests.ts", + "import": "./src/server/ProviderContinuationRequests.ts" }, "./server/ProviderEventLoggers": { "types": "./src/server/ProviderEventLoggers.ts", diff --git a/packages/provider-core/src/server/IdAllocator.ts b/packages/provider-core/src/server/IdAllocator.ts index cb31cfb5895f..3382ca49a236 100644 --- a/packages/provider-core/src/server/IdAllocator.ts +++ b/packages/provider-core/src/server/IdAllocator.ts @@ -67,7 +67,7 @@ export class IdAllocatorV2AllocationError extends Schema.TaggedError Effect.Effect; } -export interface IdAllocatorV2DeriveShape { +export interface IdAllocatorV2Derive { readonly providerSession: (input: { readonly providerInstanceId: ProviderInstanceId; }) => ProviderSessionId; @@ -180,14 +180,13 @@ export interface IdAllocatorV2DeriveShape { readonly approvalTurnItem: (input: { readonly requestId: RuntimeRequestId }) => TurnItemId; } -export interface IdAllocatorV2Shape { - readonly allocate: IdAllocatorV2AllocateShape; - readonly derive: IdAllocatorV2DeriveShape; -} - -export class IdAllocatorV2 extends Context.Service()( - "@t3tools/provider-core/server/IdAllocator/IdAllocatorV2", -) {} +export class IdAllocatorV2 extends Context.Service< + IdAllocatorV2, + { + readonly allocate: IdAllocatorV2Allocate; + readonly derive: IdAllocatorV2Derive; + } +>()("@t3tools/provider-core/server/IdAllocator/IdAllocatorV2") {} const encodePart = (part: string | number): string => encodeURIComponent(String(part)); @@ -252,6 +251,49 @@ const randomId = ), ); +/** Deterministic id derivations; pure, so callers that only derive need no allocator. */ +export const derive: IdAllocatorV2Derive = { + providerSession: (input) => + ProviderSessionId.make( + joinId("provider-session", "provider-instance", input.providerInstanceId, "shared"), + ), + delegatedTaskNode: (input) => NodeId.make(joinId("node", "delegated-task", input.commandId)), + delegatedTaskThread: (input) => + ThreadId.make(joinId("thread", "delegated-task", input.commandId)), + delegatedTaskMessage: (input) => + MessageId.make(joinId("message", "delegated-task", input.commandId)), + delegatedTaskTurnItem: (input) => + TurnItemId.make(joinId("turn-item", "delegated-task", input.commandId)), + createdThreadTurnItem: (input) => + TurnItemId.make(joinId("turn-item", "created-thread", input.commandId)), + threadFromProviderThread: deriveThreadFromProviderThread, + run: (input) => RunId.make(joinId("run", "thread", input.threadId, "ordinal", input.ordinal)), + runAttempt: (input) => + RunAttemptId.make(joinId("run-attempt", "run", input.runId, "attempt", input.attemptOrdinal)), + rootNode: (input) => NodeId.make(joinId("node", "run", input.runId, "root")), + rootNodeAttempt: (input) => + NodeId.make(joinId("node", "run", input.runId, "attempt", input.attemptOrdinal, "root")), + userTurnItem: (input) => TurnItemId.make(joinId("turn-item", "message", input.messageId)), + runSignalTurnItem: (input) => + TurnItemId.make(joinId("turn-item", "run", input.runId, "signal", input.signal)), + providerThread: deriveProviderThread, + providerTurn: (input) => + ProviderTurnId.make( + joinId("provider-turn", "provider", input.driver, "native-turn", input.nativeTurnId), + ), + nodeFromProviderItem: (input) => + NodeId.make(joinId("node", "provider", input.driver, "native-item", input.nativeItemId)), + messageFromProviderItem: (input) => + MessageId.make(joinId("message", "provider", input.driver, "native-item", input.nativeItemId)), + turnItemFromProviderItem: (input) => + TurnItemId.make( + joinId("turn-item", "provider", input.driver, "native-item", input.nativeItemId), + ), + approvalNode: (input) => NodeId.make(joinId("node", "runtime-request", input.requestId)), + approvalTurnItem: (input) => + TurnItemId.make(joinId("turn-item", "runtime-request", input.requestId)), +}; + export const layer: Layer.Layer = Layer.succeed( IdAllocatorV2, IdAllocatorV2.of({ @@ -386,50 +428,6 @@ export const layer: Layer.Layer = Layer.succeed( make: PlanId.make, })(input), }, - derive: { - providerSession: (input) => - ProviderSessionId.make( - joinId("provider-session", "provider-instance", input.providerInstanceId, "shared"), - ), - delegatedTaskNode: (input) => NodeId.make(joinId("node", "delegated-task", input.commandId)), - delegatedTaskThread: (input) => - ThreadId.make(joinId("thread", "delegated-task", input.commandId)), - delegatedTaskMessage: (input) => - MessageId.make(joinId("message", "delegated-task", input.commandId)), - delegatedTaskTurnItem: (input) => - TurnItemId.make(joinId("turn-item", "delegated-task", input.commandId)), - createdThreadTurnItem: (input) => - TurnItemId.make(joinId("turn-item", "created-thread", input.commandId)), - threadFromProviderThread: deriveThreadFromProviderThread, - run: (input) => RunId.make(joinId("run", "thread", input.threadId, "ordinal", input.ordinal)), - runAttempt: (input) => - RunAttemptId.make( - joinId("run-attempt", "run", input.runId, "attempt", input.attemptOrdinal), - ), - rootNode: (input) => NodeId.make(joinId("node", "run", input.runId, "root")), - rootNodeAttempt: (input) => - NodeId.make(joinId("node", "run", input.runId, "attempt", input.attemptOrdinal, "root")), - userTurnItem: (input) => TurnItemId.make(joinId("turn-item", "message", input.messageId)), - runSignalTurnItem: (input) => - TurnItemId.make(joinId("turn-item", "run", input.runId, "signal", input.signal)), - providerThread: deriveProviderThread, - providerTurn: (input) => - ProviderTurnId.make( - joinId("provider-turn", "provider", input.driver, "native-turn", input.nativeTurnId), - ), - nodeFromProviderItem: (input) => - NodeId.make(joinId("node", "provider", input.driver, "native-item", input.nativeItemId)), - messageFromProviderItem: (input) => - MessageId.make( - joinId("message", "provider", input.driver, "native-item", input.nativeItemId), - ), - turnItemFromProviderItem: (input) => - TurnItemId.make( - joinId("turn-item", "provider", input.driver, "native-item", input.nativeItemId), - ), - approvalNode: (input) => NodeId.make(joinId("node", "runtime-request", input.requestId)), - approvalTurnItem: (input) => - TurnItemId.make(joinId("turn-item", "runtime-request", input.requestId)), - }, + derive, }), ); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index c6e6ef486275..086300e21878 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -632,21 +632,20 @@ export interface ProviderAdapterV2SessionRuntime { ) => Effect.Effect; } -export interface ProviderAdapterV2Shape { - readonly instanceId: ProviderInstanceId; - readonly driver: ProviderDriverKind; - readonly getCapabilities: () => Effect.Effect< - OrchestrationV2ProviderCapabilities, - ProviderAdapterV2Error - >; - readonly planSelectionTransition: ( - input: ProviderSelectionTransitionInput, - ) => Effect.Effect; - readonly openSession: ( - input: ProviderAdapterV2OpenSessionInput, - ) => Effect.Effect; -} - -export class ProviderAdapterV2 extends Context.Service()( - "@t3tools/provider-core/server/ProviderAdapter/ProviderAdapterV2", -) {} +export class ProviderAdapterV2 extends Context.Service< + ProviderAdapterV2, + { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly getCapabilities: () => Effect.Effect< + OrchestrationV2ProviderCapabilities, + ProviderAdapterV2Error + >; + readonly planSelectionTransition: ( + input: ProviderSelectionTransitionInput, + ) => Effect.Effect; + readonly openSession: ( + input: ProviderAdapterV2OpenSessionInput, + ) => Effect.Effect; + } +>()("@t3tools/provider-core/server/ProviderAdapter/ProviderAdapterV2") {} diff --git a/packages/provider-core/src/server/continuationRequests.ts b/packages/provider-core/src/server/ProviderContinuationRequests.ts similarity index 97% rename from packages/provider-core/src/server/continuationRequests.ts rename to packages/provider-core/src/server/ProviderContinuationRequests.ts index f7c8606ace14..638068eb621f 100644 --- a/packages/provider-core/src/server/continuationRequests.ts +++ b/packages/provider-core/src/server/ProviderContinuationRequests.ts @@ -59,7 +59,7 @@ export interface ProviderContinuationRequest { export class ProviderContinuationRequests extends Context.Reference<{ readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; readonly take: Effect.Effect; -}>("@t3tools/provider-core/server/continuationRequests/ProviderContinuationRequests", { +}>("@t3tools/provider-core/server/ProviderContinuationRequests", { defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }), }) {} diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 050ad0882fd9..858a7c1e22d1 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -45,33 +45,32 @@ export interface ProviderCredentials { readonly remove: Effect.Effect; } -interface ProviderHostService { - readonly paths: ProviderHostPaths; - readonly settings: { - readonly get: Effect.Effect; - /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */ - readonly withSnapshot: ( - use: (settings: ServerSettings) => Effect.Effect, - ) => Effect.Effect; - /** Every settings change after subscription, starting with the next one. */ - readonly changes: Stream.Stream; - /** Changes buffered from the moment the scoped subscription is acquired. */ - readonly subscribe: Effect.Effect, never, Scope.Scope>; - }; - /** Whether background work for `scope` should run now (client demand and host power). */ - readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; - /** - * Absolute path of a stored chat attachment, or `null` when its id does not - * resolve inside the attachments directory. - */ - readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; - /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ - readonly credentials: ( - namespace: string, - bindingId: string, - ) => Effect.Effect; -} - -export class ProviderHost extends Context.Service()( - "@t3tools/provider-core/server/ProviderHost", -) {} +export class ProviderHost extends Context.Service< + ProviderHost, + { + readonly paths: ProviderHostPaths; + readonly settings: { + readonly get: Effect.Effect; + /** Runs `use` on a settings snapshot that no settings write can change until it finishes. */ + readonly withSnapshot: ( + use: (settings: ServerSettings) => Effect.Effect, + ) => Effect.Effect; + /** Every settings change after subscription, starting with the next one. */ + readonly changes: Stream.Stream; + /** Changes buffered from the moment the scoped subscription is acquired. */ + readonly subscribe: Effect.Effect, never, Scope.Scope>; + }; + /** Whether background work for `scope` should run now (client demand and host power). */ + readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; + /** + * Absolute path of a stored chat attachment, or `null` when its id does not + * resolve inside the attachments directory. + */ + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; + /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ + readonly credentials: ( + namespace: string, + bindingId: string, + ) => Effect.Effect; + } +>()("@t3tools/provider-core/server/ProviderHost") {} diff --git a/packages/provider-core/src/server/adapterDriver.ts b/packages/provider-core/src/server/adapterDriver.ts index 7445dc7ed016..27f143239473 100644 --- a/packages/provider-core/src/server/adapterDriver.ts +++ b/packages/provider-core/src/server/adapterDriver.ts @@ -39,7 +39,7 @@ export interface ProviderAdapterDriver { readonly create: ( input: ProviderAdapterDriverCreateInput, ) => Effect.Effect< - ProviderAdapter.ProviderAdapterV2Shape, + ProviderAdapter.ProviderAdapterV2["Service"], ProviderAdapterDriverCreateError, R | Scope.Scope >; diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index b177ba926ba4..df215d319742 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -101,7 +101,7 @@ export interface ProviderInstance { ProviderConsumeResetCreditOutcome, ProviderDriverError >; - readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2Shape; + readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 8a745f83aaac..45c90822aaf3 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -13,7 +13,7 @@ import type { import * as DateTime from "effect/DateTime"; import * as Cause from "effect/Cause"; -import type * as IdAllocator from "./IdAllocator.ts"; +import * as IdAllocator from "./IdAllocator.ts"; import * as Schema from "effect/Schema"; /** Raised when portable history does not fit the target provider's context window. */ @@ -172,7 +172,6 @@ export function makeProviderFailure(input: { } export function makeProviderFailureTurnItem(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly driver: ProviderDriverKind; readonly threadId: ThreadId; readonly runId: RunId | null; @@ -186,7 +185,7 @@ export function makeProviderFailureTurnItem(input: { readonly occurredAt: DateTime.Utc; }): Extract { return { - id: input.idAllocator.derive.turnItemFromProviderItem({ + id: IdAllocator.derive.turnItemFromProviderItem({ driver: input.driver, nativeItemId: `terminal-failure:${input.providerTurnId}`, }), @@ -210,7 +209,6 @@ export function makeProviderFailureTurnItem(input: { } export function makeProviderRetryTurnItem(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; readonly driver: ProviderDriverKind; readonly threadId: ThreadId; readonly runId: RunId | null; @@ -237,7 +235,7 @@ export function makeProviderRetryTurnItem(input: { title = "Provider retry stopped"; } return { - id: input.idAllocator.derive.turnItemFromProviderItem({ + id: IdAllocator.derive.turnItemFromProviderItem({ driver: input.driver, nativeItemId: `terminal-failure:${input.providerTurnId}`, }), diff --git a/packages/provider-core/src/server/ProviderAuthFlow.test.ts b/packages/provider-core/src/server/providerAuthFlow.test.ts similarity index 99% rename from packages/provider-core/src/server/ProviderAuthFlow.test.ts rename to packages/provider-core/src/server/providerAuthFlow.test.ts index 5ea9a0fc2f3b..2351243f1e0e 100644 --- a/packages/provider-core/src/server/ProviderAuthFlow.test.ts +++ b/packages/provider-core/src/server/providerAuthFlow.test.ts @@ -16,7 +16,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; +import * as ProviderAuthFlow from "./providerAuthFlow.ts"; const instanceId = ProviderInstanceId.make("auth-flow-test"); const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; diff --git a/packages/provider-core/src/server/ProviderAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts similarity index 100% rename from packages/provider-core/src/server/ProviderAuthFlow.ts rename to packages/provider-core/src/server/providerAuthFlow.ts diff --git a/packages/provider-core/src/server/snapshotSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts index f434d35b967e..bf0996d0a15a 100644 --- a/packages/provider-core/src/server/snapshotSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Stream from "effect/Stream"; -import type * as ProviderHost from "./ProviderHost.ts"; +import * as ProviderHost from "./ProviderHost.ts"; export interface ProviderSnapshotSettings { readonly provider: Settings; @@ -27,17 +27,23 @@ export function haveProviderSnapshotSettingsChanged( return !Equal.equals(previous, next); } -export function makeProviderSnapshotSettingsSource( +/** The instance's settings paired with the host's server settings, as snapshots read them. */ +export const makeProviderSnapshotSettingsSource = ( provider: Settings, - settings: ProviderHost.ProviderHost["Service"]["settings"], -): { - readonly getSettings: Effect.Effect, ServerSettingsError>; - readonly streamSettings: Stream.Stream>; -} { - const mapSettings = (settings: ServerSettings) => - makeProviderSnapshotSettings(provider, settings); - return { - getSettings: settings.get.pipe(Effect.map(mapSettings)), - streamSettings: settings.changes.pipe(Stream.map(mapSettings)), - }; -} +): Effect.Effect< + { + readonly getSettings: Effect.Effect, ServerSettingsError>; + readonly streamSettings: Stream.Stream>; + }, + never, + ProviderHost.ProviderHost +> => + Effect.gen(function* () { + const { settings } = yield* ProviderHost.ProviderHost; + const mapSettings = (current: ServerSettings) => + makeProviderSnapshotSettings(provider, current); + return { + getSettings: settings.get.pipe(Effect.map(mapSettings)), + streamSettings: settings.changes.pipe(Stream.map(mapSettings)), + }; + }); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 5aa9e836bd73..8ebf8ea9a448 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -239,7 +239,7 @@ function providerSession(input: { } function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly providerInstanceId: ProviderInstanceId; readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 157b7dca5c1e..e007e8b6fda6 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -223,7 +223,7 @@ export const CursorDriver: ProviderDriver = { Effect.provide(CursorSdkCatalog.layer), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), getSettings: snapshotSettings.getSettings, diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index b25faa9db4e7..e8332425201b 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -58,7 +58,7 @@ import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPol import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { ProviderAdapterDriverCreateError, diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index e3201d0f03c3..a2ce6ce4a41e 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -160,7 +160,7 @@ export const GrokDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 33191bb8e46e..90071af22ed6 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -36,7 +36,7 @@ import type { MuseItem } from "./protocol.ts"; import type { MuseSdkHost } from "./sdk.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; const testLayer = Layer.mergeAll( @@ -766,7 +766,7 @@ describe("MuseAdapterV2", () => { it.effect("lets a user turn take a held Muse report turn, approvals included", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); - const offers: Array = []; + const offers: Array = []; const harness = yield* makeHarness( fake, INSTANCE_ID, diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index c834e1370ebe..558a50960f84 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -74,7 +74,7 @@ import { backgroundWorkNotification, type BackgroundWorkReport, } from "@t3tools/provider-core/server/notification"; -import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 805749f7ef8c..2b35db227ff2 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -14,7 +14,7 @@ import { makeMuseTextGeneration } from "./textGeneration.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { makeMuseAdapterV2 } from "./adapter.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -86,7 +86,7 @@ export const MuseDriver: ProviderDriver = { accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { binaryPath: effectiveConfig.binaryPath, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 8c9f5e83ae6c..0beb1c2147e6 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -459,7 +459,7 @@ function formatOpenCodeProtocolLogPayload(event: OpenCodeProtocolLogEvent) { export function makeOpenCodeProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; @@ -830,7 +830,7 @@ function taskSessionId(part: ToolPart): string | null { } function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly providerInstanceId: ProviderInstanceId; readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9c37ab2abd48..fba346598060 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -123,9 +123,9 @@ function byOpenCodeRuntime( */ function selectOpenCodeRuntimeAdapter(input: { readonly probe: OpenCodeRuntimeProbe; - readonly v1: ProviderAdapter.ProviderAdapterV2Shape; - readonly v2: ProviderAdapter.ProviderAdapterV2Shape; -}): ProviderAdapter.ProviderAdapterV2Shape { + readonly v1: ProviderAdapter.ProviderAdapterV2["Service"]; + readonly v2: ProviderAdapter.ProviderAdapterV2["Service"]; +}): ProviderAdapter.ProviderAdapterV2["Service"] { const pick = (probed: Effect.Effect) => byOpenCodeRuntime(probed, { v1: Effect.succeed(input.v1), v2: Effect.succeed(input.v2) }); const hot = pick(Effect.map(input.probe.lastSuccess, Option.getOrUndefined)); @@ -445,7 +445,7 @@ export const OpenCodeDriver: ProviderDriver ), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>( { resolveMaintenance, diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 97429ddfde85..89fb48210033 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -88,7 +88,7 @@ import { backgroundWorkNotification, type BackgroundWorkReport, } from "@t3tools/provider-core/server/notification"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeSubagentChildThread, @@ -858,7 +858,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); const openSession = Effect.fn("OpenCode2Adapter.openSession")(function* ( - input: Parameters[0], + input: Parameters[0], initial: { readonly connection: OpenCode2Server.OpenCode2Connection; readonly scope: Scope.Closeable; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index ed3236d763c7..b6942a4d529e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -32,7 +32,7 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index e0d93d51a010..d2709307c985 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -67,7 +67,7 @@ import { parsePiCompactCommand, type PiCompactCommand } from "./commands.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -775,7 +775,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( type: "turn_item.updated", driver: PI_PROVIDER, turnItem: makeProviderRetryTurnItem({ - idAllocator, driver: PI_PROVIDER, threadId: turn.turnInput.threadId, runId: turn.turnInput.runId, diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index 25d390911781..c1507b861ca5 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -142,7 +142,7 @@ export const PiDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, getSettings: snapshotSettings.getSettings, From 68ab16f3084236871685bed5032cb05c7c436dbd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 09:15:21 -0700 Subject: [PATCH 070/377] refactor(provider-core): latest-version lookups go through a ProviderLatestVersions service (#17434) Co-authored-by: Claude Opus 5.5 --- .../AcpRegistryOrchestratorV2.live.test.ts | 2 + .../CursorOrchestratorV2.live.test.ts | 2 + .../GrokOrchestratorV2.live.test.ts | 2 + .../OpenCode2OrchestratorV2.live.test.ts | 2 + .../src/provider/Drivers/ClaudeDriver.ts | 4 + .../src/provider/Drivers/CodexDriver.test.ts | 11 ++- .../src/provider/Drivers/CodexDriver.ts | 4 + .../provider/ProviderInstanceRegistry.test.ts | 3 + .../src/provider/ProviderRegistry.test.ts | 5 ++ .../providerMaintenanceRunner.test.ts | 4 +- .../src/provider/providerMaintenanceRunner.ts | 11 ++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 6 +- packages/provider-core/package.json | 4 + .../src/server/ProviderLatestVersions.ts | 75 +++++++++++++++++++ .../src/server/maintenanceResolver.test.ts | 22 ++---- .../src/server/maintenanceResolver.ts | 29 +------ .../provider-grok/src/server/driver.test.ts | 2 + packages/provider-grok/src/server/driver.ts | 6 +- packages/provider-grok/src/server/status.ts | 9 ++- packages/provider-muse/src/server/driver.ts | 8 ++ .../src/server/maintenance.test.ts | 15 ++-- .../provider-muse/src/server/maintenance.ts | 35 ++++----- .../src/server/driver.test.ts | 3 + .../provider-opencode/src/server/driver.ts | 4 + .../provider-pi/src/server/driver.test.ts | 2 + packages/provider-pi/src/server/driver.ts | 6 +- packages/provider-pi/src/server/status.ts | 9 ++- 28 files changed, 196 insertions(+), 91 deletions(-) create mode 100644 packages/provider-core/src/server/ProviderLatestVersions.ts diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index f39c1c211532..0b9cd6b5cd2a 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -29,6 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; @@ -112,6 +113,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), + ProviderLatestVersions.layer, ModelManifest.layerTest, AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 044f3fd8da9f..1aefd49a5fa3 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -29,6 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; @@ -92,6 +93,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), + ProviderLatestVersions.layer, ModelManifest.layerTest, AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index abdf28155a06..8033a3d4f26e 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -29,6 +29,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; @@ -91,6 +92,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), + ProviderLatestVersions.layer, ModelManifest.layerTest, AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index b3d025ed0bda..3dda6be286de 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -54,6 +54,7 @@ import * as CodexInstallation from "../provider/CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; @@ -185,6 +186,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi ProviderEventLoggers.ProviderEventLoggers, ProviderEventLoggers.NoOpProviderEventLoggers, ), + ProviderLatestVersions.layer, ModelManifest.layerTest, AntigravityInstallation.AntigravityInstallation.layer.pipe( Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 33293d2bb114..f7935bb8d481 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -24,6 +24,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -100,6 +101,7 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | ModelCatalog.ModelCatalog | Path.Path | ProviderEventLoggers.ProviderEventLoggers @@ -120,6 +122,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const { cwd } = yield* ServerConfig.ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const catalogService = yield* ModelCatalog.ModelCatalog; const modelCatalog = catalogService @@ -256,6 +259,7 @@ export const ClaudeDriver: ProviderDriver = { }), ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), }).pipe( diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 296cc27eb605..2dd0558bc357 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -26,11 +26,11 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ModelManifest from "../ModelManifest.ts"; import { createProviderVersionAdvisory, - ProviderVersionCache, resolveLatestProviderVersion, } from "@t3tools/provider-core/server/maintenanceResolver"; import { CodexDriver } from "./CodexDriver.ts"; @@ -75,6 +75,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge( Layer.succeed( HttpClient.HttpClient, @@ -557,11 +558,9 @@ it.layer(layerTest)("CodexDriver", (it) => { }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); const capabilities = yield* instance.snapshot.resolveMaintenance(); const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( - Effect.provideService( - ProviderVersionCache, - new Map([ - ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], - ]), + Effect.provideServiceEffect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([["@openai/codex", "0.153.4"]]), ), ); expect(probes).toEqual([]); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 0759be9b868d..e27a278f34c3 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -31,6 +31,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -115,6 +116,7 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | ModelCatalog.ModelCatalog | Path.Path | ProviderEventLoggers.ProviderEventLoggers @@ -147,6 +149,7 @@ export const CodexDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const modelCatalog = yield* ModelCatalog.ModelCatalog; const currentCatalog = modelCatalog.current(DRIVER_KIND); const processEnv = mergeProviderInstanceEnvironment(environment); @@ -251,6 +254,7 @@ export const CodexDriver: ProviderDriver = { }), ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), }).pipe( diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 81208e54091e..c56926c265c0 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -35,6 +35,7 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import type { GrokSettings } from "@t3tools/provider-grok/settings"; import type { CursorSettings } from "@t3tools/provider-cursor/settings"; import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; @@ -228,6 +229,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); @@ -609,6 +611,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), ); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f0ed96254414..8755598251ba 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -45,6 +45,7 @@ import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; @@ -2759,6 +2760,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -2862,6 +2864,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -2980,6 +2983,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( @@ -3038,6 +3042,7 @@ it.layer( ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index a3c494e504b0..3b820093542c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -26,9 +26,9 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { makeProviderMaintenanceCapabilities, - ProviderVersionCache, type ProviderMaintenanceCapabilities, } from "@t3tools/provider-core/server/maintenanceResolver"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -239,7 +239,7 @@ const makeTestRunner = ( refreshInBackground: Effect.void, }), // Fresh per runner so a version cached by one test cannot leak into another. - Layer.sync(ProviderVersionCache, () => new Map()), + ProviderLatestVersions.layer, ), ), ), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 5ef57660e01f..9354e14d760d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -33,8 +33,8 @@ import { makeTargetedProviderUpdateAction, resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, - ProviderVersionCache, } from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import type { ProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -291,7 +291,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const versionCache = yield* ProviderVersionCache; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const runMaintenanceCommand = ( update: ProviderMaintenanceCommandAction, onProgress: (line: string) => Effect.Effect, @@ -356,7 +356,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { maintenanceCapabilities, ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderVersionCache, versionCache), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ), { concurrency: "unbounded", @@ -463,7 +463,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { targetVersion ?? (yield* resolveLatestProviderVersion(fresh).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderVersionCache, versionCache), + Effect.provideService( + ProviderLatestVersions.ProviderLatestVersions, + latestVersions, + ), )); const advisory = resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ab93e814e700..daadc8c48fc0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,7 @@ import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; import * as EventNdjsonLogger from "./provider/EventNdjsonLogger.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; @@ -634,6 +635,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( layerProviderEventLoggers, ModelManifest.layerModelCatalog.pipe(Layer.provideMerge(ModelManifest.layer)), ResetCreditCoordinator.layer, + ProviderLatestVersions.layer, ), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1b045f7e963f..a035d6971d45 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -168,7 +168,7 @@ import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.t import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; import { makeProviderInstallation } from "./provider/providerInstallation.ts"; @@ -1271,7 +1271,7 @@ const layerWsRpc = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const modelManifest = yield* ModelManifest.ModelManifest; - const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; + const providerLatestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const acpRegistryCatalog = yield* AcpRegistrySupport.AcpRegistryCatalog; const acpRegistryRuntimeCoordinator = @@ -2193,7 +2193,7 @@ const layerWsRpc = ( fresh: true, }); if (maintenance.packageName) - providerVersionCache.delete(maintenance.packageName); + yield* providerLatestVersions.invalidate(maintenance.packageName); }), { concurrency: "unbounded", discard: true }, ); diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index 2b7b653b6baa..78a5031be098 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -115,6 +115,10 @@ "types": "./src/server/ProviderHost.ts", "import": "./src/server/ProviderHost.ts" }, + "./server/ProviderLatestVersions": { + "types": "./src/server/ProviderLatestVersions.ts", + "import": "./src/server/ProviderLatestVersions.ts" + }, "./server/randomUuid": { "types": "./src/server/randomUuid.ts", "import": "./src/server/randomUuid.ts" diff --git a/packages/provider-core/src/server/ProviderLatestVersions.ts b/packages/provider-core/src/server/ProviderLatestVersions.ts new file mode 100644 index 000000000000..b26c0eac37ec --- /dev/null +++ b/packages/provider-core/src/server/ProviderLatestVersions.ts @@ -0,0 +1,75 @@ +/** + * Memoizes "latest available version" lookups (npm registry, release + * channels) for the version advisory. One instance is shared by every driver + * and the maintenance runner, so a provider update can invalidate the entry + * the next snapshot refresh reads. + * + * @module provider-core/server/ProviderLatestVersions + */ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +/** A found version stays fresh this long. */ +const FOUND_TTL = Duration.hours(1); +/** A failed lookup is retried sooner, so a flaky network does not hide an update for an hour. */ +const MISSING_TTL = Duration.minutes(1); + +interface Entry { + readonly expiresAt: number; + readonly version: string | null; +} + +export class ProviderLatestVersions extends Context.Service< + ProviderLatestVersions, + { + /** + * Returns the cached version for `key`, running `lookup` when there is no + * fresh entry or `fresh` is set. `null` means the lookup found nothing. + */ + readonly cached: ( + key: string, + lookup: Effect.Effect, + options?: { readonly fresh?: boolean }, + ) => Effect.Effect; + readonly invalidate: (key: string) => Effect.Effect; + } +>()("@t3tools/provider-core/server/ProviderLatestVersions") {} + +/** Starts from `seed`, for tests that need a cached entry. */ +export const make = (seed: Iterable = []) => + Effect.gen(function* () { + const entries = yield* Ref.make( + new Map( + Array.from(seed, ([key, version]) => [ + key, + { version, expiresAt: Number.MAX_SAFE_INTEGER }, + ]), + ), + ); + return ProviderLatestVersions.of({ + cached: (key, lookup, options) => + Effect.gen(function* () { + const now = DateTime.toEpochMillis(yield* DateTime.now); + const existing = (yield* Ref.get(entries)).get(key); + if (!options?.fresh && existing && existing.expiresAt > now) return existing.version; + const version = yield* lookup; + const ttl = version === null ? MISSING_TTL : FOUND_TTL; + yield* Ref.update(entries, (current) => + new Map(current).set(key, { version, expiresAt: now + Duration.toMillis(ttl) }), + ); + return version; + }), + invalidate: (key) => + Ref.update(entries, (current) => { + const next = new Map(current); + next.delete(key); + return next; + }), + }); + }); + +export const layer = Layer.effect(ProviderLatestVersions, make()); diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index f93f35624c43..6d0dd9cae93f 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -15,6 +15,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; import { createProviderVersionAdvisory, makeTargetedProviderUpdateAction, @@ -26,7 +27,6 @@ import { normalizeCommandPath, npmGlobalPrefixFromCommandPath, parseHomebrewLatestVersion, - ProviderVersionCache, resolveLatestProviderVersion, resolvePackageManagedProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, @@ -124,19 +124,11 @@ function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray) = } it.layer(NodeServices.layer)("providerMaintenance", (it) => { - it.effect("reads cached versions through the injectable cache reference", () => + it.effect("reads cached versions through ProviderLatestVersions", () => resolveLatestProviderVersion(manualPackageTool).pipe( - Effect.provideService( - ProviderVersionCache, - new Map([ - [ - "@example/package-tool", - { - expiresAt: Number.MAX_SAFE_INTEGER, - version: "9.9.9", - }, - ], - ]), + Effect.provideServiceEffect( + ProviderLatestVersions.ProviderLatestVersions, + ProviderLatestVersions.make([["@example/package-tool", "9.9.9"]]), ), Effect.provideService( HttpClient.HttpClient, @@ -152,7 +144,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { it.effect("prefers the installer's own latest version over the npm registry", () => resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe( - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), Effect.provideService( HttpClient.HttpClient, HttpClient.make(() => @@ -169,7 +161,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, { enableProviderUpdateChecks: false, }).pipe( - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), Effect.provideService( HttpClient.HttpClient, HttpClient.make(() => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 65667281ac6a..ddb26fc91673 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -17,7 +17,6 @@ import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Cache from "effect/Cache"; import * as Config from "effect/Config"; -import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -30,8 +29,8 @@ import { HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { collectUint8StreamText } from "./collectStreamText.ts"; +import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; -const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const HOMEBREW_INFO_TIMEOUT_MS = 10_000; const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; @@ -102,17 +101,6 @@ export interface PackageManagedProviderMaintenanceDefinition { } | null; } -export interface ProviderVersionCacheEntry { - readonly expiresAt: number; - readonly version: string | null; -} - -export const ProviderVersionCache = Context.Reference>( - "@t3tools/server/providerMaintenance/ProviderVersionCache", - { - defaultValue: () => new Map(), - }, -); const NpmLatestVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); @@ -784,19 +772,8 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } - const latestVersionCache = yield* ProviderVersionCache; - const cached = latestVersionCache.get(packageName); - const now = DateTime.toEpochMillis(yield* DateTime.now); - if (cached && cached.expiresAt > now) { - return cached.version; - } - - const version = yield* fetchNpmLatestVersion(packageName); - latestVersionCache.set(packageName, { - expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, - version, - }); - return version; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + return yield* latestVersions.cached(packageName, fetchNpmLatestVersion(packageName)); }); export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index 1885a11d22e6..cfc4cf84df1c 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { GrokDriver } from "./driver.ts"; @@ -18,6 +19,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge(ProviderLatestVersions.layer), Layer.provideMerge( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index a2ce6ce4a41e..fba71962704e 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeGrokTextGeneration } from "./textGeneration.ts"; @@ -77,6 +78,7 @@ export type GrokDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | Path.Path | ProviderEventLoggers.ProviderEventLoggers; @@ -93,6 +95,7 @@ export const GrokDriver: ProviderDriver = { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; @@ -177,9 +180,10 @@ export const GrokDriver: ProviderDriver = { maintenanceCapabilities, enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, - httpClient, }), ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ), }).pipe( Effect.mapError( diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 0d8dc59b0ddc..e9d7caab677a 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -567,14 +568,16 @@ export const enrichGrokSnapshot = (input: { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; - readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { +}): Effect.Effect< + void, + never, + HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions +> => { const { snapshot, publishSnapshot } = input; return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { enableProviderUpdateChecks: input.enableProviderUpdateChecks, }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), Effect.catchCause((cause) => Effect.logWarning("Grok version advisory enrichment failed", { diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 2b35db227ff2..a043fd7c0135 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { makeMuseTextGeneration } from "./textGeneration.ts"; @@ -46,6 +47,7 @@ export type MuseDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | Path.Path | ProviderEventLoggers.ProviderEventLoggers; @@ -60,6 +62,7 @@ export const MuseDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const host = yield* ProviderHost.ProviderHost; const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; const { cwd } = host.paths; @@ -105,6 +108,10 @@ export const MuseDriver: ProviderDriver = { const latestVersion = options?.fresh ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderLatestVersions.ProviderLatestVersions, + latestVersions, + ), ) : undefined; return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; @@ -133,6 +140,7 @@ export const MuseDriver: ProviderDriver = { }), ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), Effect.flatMap(publishSnapshot), ), }).pipe( diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts index 1ac4a48cae60..244d99a89c70 100644 --- a/packages/provider-muse/src/server/maintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -12,10 +12,8 @@ import { latestMuseVersion, parseMuseVersion, } from "./maintenance.ts"; -import { - ProviderVersionCache, - makeManualOnlyProviderMaintenanceCapabilities, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; const maintenanceCapabilities = { ...makeManualOnlyProviderMaintenanceCapabilities({ @@ -50,7 +48,7 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { expect(yield* latestMuseVersion({}, { fresh: true })).toBe("1.1.1-R10.1"); }); yield* check.pipe( - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => { @@ -101,7 +99,7 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { }); const [first, second] = yield* Effect.all([check, check], { concurrency: 1 }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), ); expect(first.versionAdvisory).toMatchObject({ status: "behind_latest", @@ -134,7 +132,7 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { ), ), ), - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), ); expect(result.versionAdvisory?.status).toBe(status); } @@ -153,6 +151,7 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { maintenanceCapabilities, environment: {}, }).pipe( + Effect.provide(ProviderLatestVersions.layer), Effect.provideService( HttpClient.HttpClient, HttpClient.make(() => Effect.die("Unexpected release request")), @@ -182,7 +181,7 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { Effect.succeed(HttpClientResponse.fromWeb(request, response)), ), ), - Effect.provideService(ProviderVersionCache, new Map()), + Effect.provide(ProviderLatestVersions.layer), ); expect(result.status).toBe("ready"); expect(result.versionAdvisory?.status).toBe("unknown"); diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 0770b494a819..107fa77bb606 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -1,6 +1,5 @@ import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -10,10 +9,10 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeManualOnlyProviderMaintenanceCapabilities, makeProviderMaintenanceCapabilities, - ProviderVersionCache, type ProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, } from "@t3tools/provider-core/server/maintenanceResolver"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; const DRIVER = ProviderDriverKind.make("muse"); @@ -97,26 +96,24 @@ export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( const channel = environment.MUSE_CHANNEL || "muse-stable"; if (!MUSE_CHANNELS.has(channel)) return null; const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`; - const cache = yield* ProviderVersionCache; - const key = `muse:${channelUrl}`; - const now = DateTime.toEpochMillis(yield* DateTime.now); - const cached = cache.get(key); - if (!options?.fresh && cached && cached.expiresAt > now) return cached.version; const client = yield* HttpClient.HttpClient; - const version = yield* client.execute(HttpClientRequest.get(channelUrl)).pipe( - Effect.flatMap((response) => - response.status >= 200 && response.status < 300 - ? response.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)), - Effect.map((manifest) => parseMuseVersion(manifest.version)), - ) - : Effect.succeed(null), + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; + return yield* latestVersions.cached( + `muse:${channelUrl}`, + client.execute(HttpClientRequest.get(channelUrl)).pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)), + Effect.map((manifest) => parseMuseVersion(manifest.version)), + ) + : Effect.succeed(null), + ), + Effect.timeout(4_000), + Effect.orElseSucceed(() => null), ), - Effect.timeout(4_000), - Effect.orElseSucceed(() => null), + options, ); - cache.set(key, { version, expiresAt: now + (version === null ? 60_000 : 60 * 60 * 1_000) }); - return version; }); /** Feed Muse's release channel into the same advisory and notification flow as other drivers. */ diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 10410d3abab9..a8f48d937f96 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -15,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; @@ -48,6 +49,7 @@ const openCode2Runtime = { const layer = Layer.mergeAll( IdAllocator.layer, + ProviderLatestVersions.layer, layerTestProviderHost(), Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -210,6 +212,7 @@ const changingRuntime = { } as unknown as OpenCodeRuntime.OpenCodeRuntimeShape; const layerUpdate = Layer.mergeAll( IdAllocator.layer, + ProviderLatestVersions.layer, layerTestProviderHost(), Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index fba346598060..53a5f05424f3 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -27,6 +27,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as OpenCodeAdapterV2 from "./adapter.ts"; import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; @@ -177,6 +178,7 @@ export type OpenCodeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | OpenCodeRuntime.OpenCodeRuntime | Path.Path; @@ -195,6 +197,7 @@ export const OpenCodeDriver: ProviderDriver const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const crypto = yield* Crypto.Crypto; const host = yield* ProviderHost.ProviderHost; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -465,6 +468,7 @@ export const OpenCodeDriver: ProviderDriver }), ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), }, diff --git a/packages/provider-pi/src/server/driver.test.ts b/packages/provider-pi/src/server/driver.test.ts index 83a6f33eb57c..4de26a8b1fa4 100644 --- a/packages/provider-pi/src/server/driver.test.ts +++ b/packages/provider-pi/src/server/driver.test.ts @@ -13,6 +13,7 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import type { PiSettings } from "../settings.ts"; @@ -25,6 +26,7 @@ const layerTest = Layer.mergeAll( runBackgroundWork: false, }), IdAllocator.layer, + ProviderLatestVersions.layer, Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("Unexpected HTTP")), diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index c1507b861ca5..9d6962c4cd47 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makePiTextGeneration } from "./textGeneration.ts"; import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; @@ -60,6 +61,7 @@ export type PiDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient + | ProviderLatestVersions.ProviderLatestVersions | Path.Path; const withInstanceIdentity = @@ -92,6 +94,7 @@ export const PiDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const host = yield* ProviderHost.ProviderHost; const { cwd } = host.paths; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -159,9 +162,10 @@ export const PiDriver: ProviderDriver = { maintenanceCapabilities, enableProviderUpdateChecks: settings.enableProviderUpdateChecks, publishSnapshot, - httpClient, }), ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), ), }).pipe( Effect.mapError( diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 54be47d3dfd9..9969a244b5ad 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -34,6 +34,7 @@ import { piRecordField as recordField, piRecordString as recordString, } from "./rpc.ts"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { buildServerProvider, isCommandMissingCause, @@ -461,13 +462,15 @@ export const enrichPiSnapshot = (input: { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; - readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { +}): Effect.Effect< + void, + never, + HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions +> => { const { snapshot, publishSnapshot } = input; return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { enableProviderUpdateChecks: input.enableProviderUpdateChecks, }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), Effect.catchCause((cause) => Effect.logWarning("Pi version advisory enrichment failed", { From 784b5625f9e228cca8dd67e6e24e402325672544 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 09:15:22 -0700 Subject: [PATCH 071/377] refactor(provider-core): MCP provider sessions live in a McpProviderSessions service (#17446) Co-authored-by: Claude Opus 5.5 --- ...OrchestratorMcpToolkit.integration.test.ts | 5 +- .../AcpRegistryOrchestratorV2.live.test.ts | 2 + .../Adapters/AcpAdapterV2.test.ts | 36 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 2 + .../Adapters/AntigravityAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.test.ts | 854 ++++++++++++------ .../Adapters/ClaudeAdapterV2.ts | 20 +- .../Adapters/CodexAdapterV2.test.ts | 479 ++++++++-- .../Adapters/CodexAdapterV2.ts | 36 +- .../Adapters/CursorAdapterV2.testkit.ts | 1 + .../Adapters/GrokAdapterV2.test.ts | 8 +- .../Adapters/OpenCode2AdapterV2.test.ts | 68 +- ...laudeAutomaticDelivery.integration.test.ts | 9 +- .../CursorOrchestratorV2.live.test.ts | 2 + .../DelegatedCompletionDelivery.test.ts | 2 + .../GrokOrchestratorV2.live.test.ts | 2 + .../OpenCode2OrchestratorV2.live.test.ts | 2 + ...ProjectSettingsUpgrade.integration.test.ts | 2 + .../ProviderSessionManager.test.ts | 76 +- .../ProviderSessionManager.ts | 30 +- .../src/orchestration-v2/runtimeLayer.test.ts | 5 + .../testkit/ProviderReplayHarness.ts | 18 +- .../Drivers/AntigravityDriver.test.ts | 2 + .../src/provider/Drivers/AntigravityDriver.ts | 2 + .../src/provider/Drivers/CodexDriver.test.ts | 2 + .../provider/ProviderInstanceRegistry.test.ts | 3 + .../src/provider/ProviderRegistry.test.ts | 5 + apps/server/src/server.ts | 2 + .../src/server/adapter.ts | 2 + packages/provider-acp/src/server/adapter.ts | 101 ++- packages/provider-core/package.json | 4 + .../src/server/McpProviderSessions.ts | 40 + .../provider-core/src/server/mcpSession.ts | 18 - .../src/server/adapter.test.ts | 83 +- .../provider-cursor/src/server/adapter.ts | 20 +- .../provider-cursor/src/server/driver.test.ts | 2 + packages/provider-grok/src/server/adapter.ts | 3 + .../provider-grok/src/server/driver.test.ts | 2 + .../provider-muse/src/server/adapter.test.ts | 13 +- packages/provider-muse/src/server/adapter.ts | 6 +- packages/provider-muse/src/server/driver.ts | 2 + .../src/server/adapter.test.ts | 114 ++- .../provider-opencode/src/server/adapter.ts | 6 +- .../src/server/driver.test.ts | 3 + .../src/server/v2/adapter.ts | 5 +- .../provider-pi/src/server/adapter.test.ts | 11 +- packages/provider-pi/src/server/adapter.ts | 6 +- .../provider-pi/src/server/driver.test.ts | 2 + 48 files changed, 1467 insertions(+), 653 deletions(-) create mode 100644 packages/provider-core/src/server/McpProviderSessions.ts diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 02c780b35946..d232386dec88 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -59,6 +59,7 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import { @@ -3810,7 +3811,9 @@ describe("orchestrator MCP toolkit", () => { Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(layerOrchestration), Layer.provide( - CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript), + CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe( + Layer.provide(McpProviderSessions.layer), + ), ), Layer.provide(layerProviderRegistry), Layer.provide(layerUnusedScheduledTaskStub), diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 0b9cd6b5cd2a..da89b3bc6227 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -30,6 +30,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ModelManifest from "../provider/ModelManifest.ts"; import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; @@ -144,6 +145,7 @@ const layerLive = RuntimeLayer.layer.pipe( Layer.provide(layerBackgroundPolicy), Layer.provide(ProviderTurnStartServiceTestkit.layer), Layer.provide(layerPlatformTest), + Layer.provide(McpProviderSessions.layer), ); const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* ( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index f1f33d43233f..697da040bef6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -54,7 +54,7 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { extractXAiAcpSubagentEndNotice, @@ -96,7 +96,12 @@ const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); const layerHost = layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)); -const layerTest = Layer.mergeAll(NodeServices.layer, IdAllocator.layer, layerHost); +const layerTest = Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + McpProviderSessions.layer, + layerHost, +); const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -708,7 +713,7 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("acp-test-self-contained-mcp-bridge"); const threadId = ThreadId.make("thread-acp-self-contained-mcp-bridge"); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("environment-acp-self-contained-mcp-bridge"), threadId, providerSessionId: "mcp-session-acp-self-contained-mcp-bridge", @@ -717,11 +722,6 @@ describe("AcpAdapterV2", () => { authorizationHeader: "Bearer self-contained-mcp-bridge-token", browserToolsAvailable: false, }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - McpProviderSession.clearMcpProviderSession(threadId); - }), - ); let runtimeInput: AcpAdapterV2RuntimeInput | undefined; const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath }); @@ -772,7 +772,7 @@ describe("AcpAdapterV2", () => { const protocolEvents = yield* Queue.unbounded(); const instanceId = ProviderInstanceId.make("acp-test-instruction-transitions"); const threadId = ThreadId.make("thread-acp-instruction-transitions"); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("environment-acp-instruction-transitions"), threadId, providerSessionId: "mcp-session-acp-instruction-transitions", @@ -781,9 +781,6 @@ describe("AcpAdapterV2", () => { authorizationHeader: "Bearer instruction-transition-token", browserToolsAvailable: false, }); - yield* Effect.addFinalizer(() => - Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), - ); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { @@ -2197,7 +2194,7 @@ describe("AcpAdapterV2", () => { }); const sourceThreadId = ThreadId.make("thread-acp-native-fork-source"); const targetThreadId = ThreadId.make("thread-acp-native-fork-target"); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("environment-acp-native-fork-source"), threadId: sourceThreadId, providerSessionId: "mcp-session-acp-native-fork-source", @@ -2206,7 +2203,7 @@ describe("AcpAdapterV2", () => { authorizationHeader: "Bearer source-thread-token", browserToolsAvailable: true, }); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("environment-acp-native-fork"), threadId: targetThreadId, providerSessionId: "mcp-session-acp-native-fork", @@ -2215,12 +2212,6 @@ describe("AcpAdapterV2", () => { authorizationHeader: "Bearer target-thread-token", browserToolsAvailable: true, }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - McpProviderSession.clearMcpProviderSession(sourceThreadId); - McpProviderSession.clearMcpProviderSession(targetThreadId); - }), - ); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -2640,7 +2631,7 @@ describe("AcpAdapterV2", () => { ); const instanceId = ProviderInstanceId.make("acp-test-rollback-session"); const rollbackThreadId = ThreadId.make("thread-acp-rollback-session-target"); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("environment-acp-rollback-session-target"), threadId: rollbackThreadId, providerSessionId: "mcp-session-acp-rollback-session-target", @@ -2649,9 +2640,6 @@ describe("AcpAdapterV2", () => { authorizationHeader: "Bearer rollback-target-token", browserToolsAvailable: false, }); - yield* Effect.addFinalizer(() => - Effect.sync(() => McpProviderSession.clearMcpProviderSession(rollbackThreadId)), - ); const runtimeInputs: Array = []; const makeRuntime = makeMockRuntime({ childProcessSpawner, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index c640ae354919..17ff9b76ff44 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -14,6 +14,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as EffectAcpErrors from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as ServerSettings from "../../serverSettings.ts"; import type { @@ -84,6 +85,7 @@ const layerRegistry = Layer.succeed( const layerTest = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, + McpProviderSessions.layer, layerHost, layerRegistry, ServerSettings.layerTest(), diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 9579da1fa91c..ca19c1f86c50 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -23,6 +23,7 @@ import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; @@ -129,6 +130,7 @@ describe("AntigravityAdapterV2 flavor", () => { const layerSession = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, + McpProviderSessions.layer, layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index eed58e1aa416..9c4e336815f7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -53,6 +53,7 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; @@ -483,26 +484,22 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token", } as const; - const withMcpSession = (threadId: ThreadId, run: () => void) => { - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make(`environment-${threadId}`), - threadId, - providerSessionId: `mcp-session-${threadId}`, - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer secret-claude-token", - browserToolsAvailable: true, - }); - try { - run(); - } finally { - McpProviderSession.clearMcpProviderSession(threadId); - } - }; + const mcpSessionFor = ( + threadId: ThreadId, + authorizationHeader = "Bearer secret-claude-token", + ): McpProviderSession.McpProviderSessionConfig => ({ + environmentId: EnvironmentId.make(`environment-${threadId}`), + threadId, + providerSessionId: `mcp-session-${threadId}`, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader, + browserToolsAvailable: true, + }); it("leaves an absent allowlist absent when no MCP session exists", () => { const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId: ThreadId.make("thread-claude-no-mcp-no-allowlist"), + mcpSession: undefined, readOnlySandbox: false, }); @@ -511,7 +508,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { it("preserves an explicit allowlist when no MCP session exists", () => { const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId: ThreadId.make("thread-claude-no-mcp-with-allowlist"), + mcpSession: undefined, readOnlySandbox: false, allowedTools: ["Read"], }); @@ -521,135 +518,122 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { it("pre-approves all t3-code tools when attaching an MCP session without an allowlist", () => { const threadId = ThreadId.make("thread-claude-mcp-no-allowlist"); - withMcpSession(threadId, () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId, - readOnlySandbox: false, - }); + const mcpSession = mcpSessionFor(threadId); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: false, + }); - assert.deepEqual(overrides, { - allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], - mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, - }); + assert.deepEqual(overrides, { + allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], + mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); it("extends an explicit allowlist with the t3-code wildcard", () => { const threadId = ThreadId.make("thread-claude-mcp-with-allowlist"); - withMcpSession(threadId, () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId, - readOnlySandbox: false, - allowedTools: ["Read", "mcp__t3-code__*"], - }); + const mcpSession = mcpSessionFor(threadId); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: false, + allowedTools: ["Read", "mcp__t3-code__*"], + }); - assert.deepEqual(overrides, { - allowedTools: ["Read", "mcp__t3-code__*"], - mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, - }); + assert.deepEqual(overrides, { + allowedTools: ["Read", "mcp__t3-code__*"], + mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); it("pre-approves only read-only t3-code tools in a read-only sandbox", () => { const threadId = ThreadId.make("thread-claude-mcp-read-only"); - withMcpSession(threadId, () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId, - readOnlySandbox: true, - allowedTools: [...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS], - }); + const mcpSession = mcpSessionFor(threadId); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: true, + allowedTools: [...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS], + }); - assert.deepEqual(overrides, { - allowedTools: [ - ...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS, - ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, - ], - mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, - }); - assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); + assert.deepEqual(overrides, { + allowedTools: [ + ...ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS, + ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, + ], + mcpServers: T3_MCP_SERVERS, + mcpEnvironment: T3_MCP_ENVIRONMENT, }); + assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); }); it("pre-approves only read-only t3-code tools in a read-only sandbox without an allowlist", () => { const threadId = ThreadId.make("thread-claude-mcp-read-only-no-allowlist"); - withMcpSession(threadId, () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId, - readOnlySandbox: true, - }); - - assert.deepEqual(overrides.allowedTools, [ - ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, - ]); + const mcpSession = mcpSessionFor(threadId); + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: true, }); + + assert.deepEqual(overrides.allowedTools, [ + ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, + ]); }); it("keys live-query reuse on the MCP-derived pre-approvals", () => { const threadId = ThreadId.make("thread-claude-mcp-query-key"); - withMcpSession(threadId, () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - approvalPolicy: "on-request", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }), - ); + const mcpSession = mcpSessionFor(threadId); + const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }), + ); - const readOnlyKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ threadId, readOnlySandbox: true }), - ); - const fullAccessKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }), - ); - const detachedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(queryPolicy, {}); + const readOnlyKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( + queryPolicy, + ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: true }), + ); + const fullAccessKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( + queryPolicy, + ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: false }), + ); + const detachedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(queryPolicy, {}); - assert.notEqual(readOnlyKey, fullAccessKey); - assert.notEqual(fullAccessKey, detachedKey); - }); + assert.notEqual(readOnlyKey, fullAccessKey); + assert.notEqual(fullAccessKey, detachedKey); }); it("invalidates live-query reuse when MCP credentials rotate", () => { const threadId = ThreadId.make("thread-claude-mcp-credential-rotation"); - withMcpSession(threadId, () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: "/workspace", - }), - ); - const initialKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }), - ); - - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make(`environment-${threadId}`), - threadId, - providerSessionId: `mcp-session-${threadId}`, - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer rotated-claude-token", - browserToolsAvailable: true, - }); + const mcpSession = mcpSessionFor(threadId); + const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( + ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }), + ); + const initialKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( + queryPolicy, + ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: false }), + ); - const rotatedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ threadId, readOnlySandbox: false }), - ); - assert.notEqual(rotatedKey, initialKey); - }); + const rotatedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( + queryPolicy, + ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession: mcpSessionFor(threadId, "Bearer rotated-claude-token"), + readOnlySandbox: false, + }), + ); + assert.notEqual(rotatedKey, initialKey); }); it("matches the read-only allowlist to the orchestrator toolkit annotations", () => { @@ -676,7 +660,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { describe("ClaudeAdapterV2 native protocol logging", () => { it("injects thread-scoped MCP configuration without logging the credential", () => { const threadId = ThreadId.make("thread-claude-mcp"); - McpProviderSession.setMcpProviderSession({ + const mcpSession = { environmentId: EnvironmentId.make("environment-claude-mcp"), threadId, providerSessionId: "mcp-session-claude", @@ -684,60 +668,56 @@ describe("ClaudeAdapterV2 native protocol logging", () => { endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer secret-claude-token", browserToolsAvailable: true, - }); + }; - try { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - threadId, - readOnlySandbox: false, - allowedTools: ["Read"], - }); - assert.deepEqual(overrides, { - allowedTools: ["Read", "mcp__t3-code__*"], - mcpServers: { - "t3-code": { - type: "http", - url: "http://127.0.0.1:43123/mcp", - headers: { - Authorization: "${T3_CODE_MCP_AUTHORIZATION}", - }, - timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, + const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ + mcpSession, + readOnlySandbox: false, + allowedTools: ["Read"], + }); + assert.deepEqual(overrides, { + allowedTools: ["Read", "mcp__t3-code__*"], + mcpServers: { + "t3-code": { + type: "http", + url: "http://127.0.0.1:43123/mcp", + headers: { + Authorization: "${T3_CODE_MCP_AUTHORIZATION}", }, + timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, - mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" }, - }); + }, + mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" }, + }); - const options = ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }, - nativeThreadId: "native-thread-claude-mcp", - resume: false, - cwd: "/workspace", - allowedTools: overrides.allowedTools ?? [], - mcpServers: overrides.mcpServers ?? {}, - environment: { ...overrides.mcpEnvironment }, - }); - // mcpServers becomes a CLI argument, readable by every local user; the - // credential may only travel in the child's environment. - assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token"); - assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token"); - assert.isObject(options.systemPrompt); - const systemPrompt = options.systemPrompt as { - readonly type: string; - readonly preset: string; - readonly append?: string; - }; - assert.equal(systemPrompt.type, "preset"); - assert.equal(systemPrompt.preset, "claude_code"); - assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); - const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); - assert.equal(logged.hasMcpServers, true); - assert.notInclude(JSON.stringify(logged), "secret-claude-token"); - } finally { - McpProviderSession.clearMcpProviderSession(threadId); - } + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + nativeThreadId: "native-thread-claude-mcp", + resume: false, + cwd: "/workspace", + allowedTools: overrides.allowedTools ?? [], + mcpServers: overrides.mcpServers ?? {}, + environment: { ...overrides.mcpEnvironment }, + }); + // mcpServers becomes a CLI argument, readable by every local user; the + // credential may only travel in the child's environment. + assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token"); + assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token"); + assert.isObject(options.systemPrompt); + const systemPrompt = options.systemPrompt as { + readonly type: string; + readonly preset: string; + readonly append?: string; + }; + assert.equal(systemPrompt.type, "preset"); + assert.equal(systemPrompt.preset, "claude_code"); + assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); + const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); + assert.equal(logged.hasMcpServers, true); + assert.notInclude(JSON.stringify(logged), "secret-claude-token"); }); it.effect("writes Claude Agent SDK protocol frames to the native provider log", () => @@ -961,7 +941,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { prefix: "t3-claude-accept-edits-", }); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1056,7 +1036,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); }); @@ -1174,7 +1158,11 @@ describe("ClaudeAdapterV2 executable path", () => { assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); }), - ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("follows a bare claude on Windows to the npm package executable", () => @@ -1190,7 +1178,11 @@ describe("ClaudeAdapterV2 executable path", () => { assert.deepEqual(executablePaths, [packageExe]); }), - ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); }); @@ -1204,7 +1196,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { prefix: "t3-claude-resume-", }); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1408,7 +1400,11 @@ describe("ClaudeAdapterV2 resume compaction", () => { ), { behavior: "cancelled" }, ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); }); @@ -1424,7 +1420,7 @@ describe("ClaudeAdapterV2 attachments", () => { prefix: "t3-claude-v2-attachments-", }); const offeredMessages: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1552,7 +1548,11 @@ describe("ClaudeAdapterV2 attachments", () => { text: `Ultrathink:\nFocus on the diagram labels.\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`, }, ]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -1565,7 +1565,7 @@ describe("ClaudeAdapterV2 attachments", () => { prefix: "t3-claude-v2-unsupported-attachment-", }); let openCount = 0; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1634,7 +1634,11 @@ describe("ClaudeAdapterV2 attachments", () => { assert.equal(error._tag, "ProviderAdapterTurnStartError"); assert.include(String(error.cause), "Unsupported Claude image attachment type"); assert.equal(openCount, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); }); @@ -1655,7 +1659,7 @@ describe("ClaudeAdapterV2 native fork", () => { readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; }> = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1813,7 +1817,11 @@ describe("ClaudeAdapterV2 native fork", () => { assert.equal(openedQueries[0]?.options.resume, "forked-native-session"); assert.equal(openedQueries[0]?.options.sessionId, undefined); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); }); @@ -1828,7 +1836,7 @@ describe("ClaudeAdapterV2 native session identity", () => { prefix: "t3-claude-v2-session-identity-", }); const openedQueries: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1883,7 +1891,11 @@ describe("ClaudeAdapterV2 native session identity", () => { }), ); return openedQueries; - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("creates the native session on the first provider turn", () => @@ -2127,7 +2139,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Extract >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: options?.environment ?? {}, @@ -2365,7 +2377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(item.input, { city: "Berlin" }); } assert.equal(new Set(items.map((item) => item.id)).size, 1); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect( @@ -2426,7 +2443,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.strictEqual(harness.getOpenedOptions(), originalOptions); assert.lengthOf(harness.offeredMessages, 2); assert.isTrue(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2545,7 +2566,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); for (const item of latest.values()) assert.notInclude(item.text, "secret-signature"); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect.each(["cancelled", "denied", "permission_denied", undefined])( @@ -2615,7 +2641,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.type === "node.updated" && event.node.nativeItemRef?.nativeId === "tool-error", ); assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2688,7 +2718,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } assert.lengthOf(harness.terminalEvents(), 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2754,7 +2788,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.take(harness.terminalReceipts); assert.lengthOf(notices(), 3); assert.notEqual(notices()[0]?.id, notices()[2]?.id); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("keeps usage warnings and provisioned overage silent", () => @@ -2801,7 +2839,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice", ), ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("names an expired Claude login instead of the terminal API error", () => @@ -2846,7 +2888,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.include(terminal.failure.message, configDir); assert.include(terminal.failure.message, cwd); assert.notInclude(terminal.failure.message, "repeated API errors"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect.each([ @@ -2900,7 +2946,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (terminal.status !== "failed") return; assert.include(terminal.failure.message, expected); assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect.each([ @@ -2960,7 +3010,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? "Claude usage limit reached. Send the message again once the limit resets." : "Claude gave up after repeated API errors.", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect.each([429, 401, 529])( @@ -3004,7 +3058,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); if (apiErrorStatus !== 429) assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("surfaces a Claude safety model fallback without failing the turn", () => @@ -3063,7 +3121,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", ), ); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), ); it.effect( @@ -3135,7 +3198,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { cacheCreationTokens: 0, }); } - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), ); it.effect("titles Claude reads, searches, and skills on tool completion", () => @@ -3233,7 +3301,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) assert.notProperty(item, "viewedImagePath"); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), ); it.effect("preserves typed Claude plans and todos through generic tool completion", () => @@ -3442,7 +3515,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The second prompt reuses the live process, which is still in the // plan mode Claude entered, so it is put back in the thread's mode. assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3516,7 +3593,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3570,7 +3651,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); assert.isDefined(terminal.retryStartedAt); assert.equal(terminal.failure.code, "api_error_529"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3632,7 +3717,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminal.failure.code, terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3697,7 +3786,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { "empty roster clear", ); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3832,7 +3925,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(roster, [ { taskId: longRunning.taskId, kind: "monitor", description: "Monitor 0" }, ]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3882,7 +3979,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3927,7 +4028,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3944,7 +4049,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const events: Array = []; const continuationRequests: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -4109,7 +4214,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4167,7 +4276,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const afterFailure = providerThreadRosterEvents(harness.events).at(-1); assert.deepEqual(afterFailure?.providerThread.pendingBackgroundTasks ?? [], []); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4190,7 +4303,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly queue: Queue.Queue; }> = []; const events: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -4429,7 +4542,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4649,7 +4766,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { userRunItems.map((item) => item.type), ["assistant_message"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4732,7 +4853,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { : undefined, "On branch main\nwarning: dirty", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4861,7 +4986,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const result = yield* Fiber.join(permission); assert.equal(result?.behavior, "allow"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4957,7 +5086,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.message.text === STALE_TASK_NOTIFICATION_RESULT_TEXT, ), ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5035,7 +5168,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); assert.deepEqual(assistantTexts(), ["One.", "Two."]); assert.equal(harness.terminalEvents()[1]?.status, "failed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5061,7 +5198,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5098,7 +5239,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (terminal?.status !== "failed") return; assert.equal(terminal.threadDisposition, "reusable"); assert.include(terminal.failure.message, "never started a turn"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5147,7 +5292,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5193,7 +5342,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 1); assert.lengthOf(harness.terminalEvents(), 1); assert.isTrue(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5250,7 +5403,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, ), ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5301,7 +5458,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(harness.terminalEvents()[1]?.status, "completed"); assert.lengthOf(harness.offeredMessages, 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5389,7 +5550,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(pause.turnItem.type, "system_notice"); if (pause.turnItem.type !== "system_notice") return; assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5480,7 +5645,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5542,7 +5711,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "queued turn terminal"); assert.equal(harness.terminalEvents()[1]?.status, "completed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5580,7 +5753,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "message.updated" && event.message.text === fallbackText, ), ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5663,7 +5840,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.isTrue(hasMessageText(recoveryAssistantText)); assert.isFalse(hasMessageText(staleResultText)); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5713,7 +5894,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); assert.equal(harness.terminalEvents()[1]?.status, "completed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5772,7 +5957,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(terminalized); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); assert.lengthOf(harness.terminalEvents(), 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5831,7 +6020,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "message.updated" && event.message.text === staleText, ), ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5865,7 +6058,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); assert.equal(harness.terminalEvents()[0]?.status, "failed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5891,7 +6088,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "spurious terminal"); assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.lengthOf(harness.offeredMessages, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6088,7 +6289,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.continuationRequests[1]?.notification?.summary, `Command "${WAKE_TASK_DESCRIPTION}" finished`, ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6149,7 +6354,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.continuationRequests[0]?.notification?.summary, `Command "${WAKE_TASK_DESCRIPTION}" finished`, ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6305,7 +6514,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ); assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6411,7 +6624,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, "completed", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6572,7 +6789,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, "completed", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6760,7 +6981,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), ["OUTER_WORKING", "OUTER_DONE"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6890,7 +7115,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); assert.lengthOf(harness.continuationRequests, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6942,7 +7171,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.shutdown(harness.sdkMessages); yield* awaitUntil(() => bashStatuses().length === 2, "call ended"); assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7004,7 +7237,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { "command_execution:running", "command_execution:interrupted", ]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7170,7 +7407,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { `command_execution:running:${input}`, `command_execution:completed:${input}`, ]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7326,7 +7567,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { `dynamic_tool:running:${input}`, `dynamic_tool:completed:${input}`, ]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7403,7 +7648,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { "Part two.", ]); assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7578,7 +7827,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); assert.lengthOf(harness.continuationRequests, 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7639,7 +7892,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ); assert.deepEqual(retryStatuses, ["running"]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7728,7 +7985,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { FINAL_REPORT, ]); assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7835,7 +8096,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const child = harness.events.find((event) => event.type === "app_thread.created"); assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -7920,7 +8185,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8013,7 +8282,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8299,7 +8572,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(resumedWorkIndexes, 1); assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8315,7 +8592,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const events: Array = []; const continuationRequests: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -8482,7 +8759,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { () => events.filter((event) => event.type === "turn.terminal").length === 2, "second turn terminal", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8500,7 +8781,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const events: Array = []; const continuationRequests: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -8670,7 +8951,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8737,7 +9022,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const events: Array = []; const continuationRequests: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -8920,7 +9205,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -8936,7 +9225,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const processQueues: Array> = []; const events: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -9086,7 +9375,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); assert.lengthOf(processQueues, 3); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -9103,7 +9396,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { let openCount = 0; const processQueues: Array> = []; const events: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -9215,7 +9508,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { // no replacement is opened. assert.equal(openCount, 1); assert.isTrue(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -9234,7 +9531,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const events: Array = []; const continuationRequests: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -9416,7 +9713,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { "continuation request after retry", ); assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -9429,7 +9730,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-first-open-fail-", }); const events: Array = []; - const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -9491,7 +9792,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(Exit.isFailure(failedStart)); // No live process ever existed: do not emit a fabricated empty roster. assert.lengthOf(providerThreadRosterEvents(events), 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -9572,7 +9877,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, { objective: condition, status: "complete", checks: 1 }, ]); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("keeps a goal active when a hook stops the turn before the goal passes", () => @@ -9606,7 +9916,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { status: "active", checks: 0, }); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("keeps a goal active after a command turn with no model output", () => @@ -9640,7 +9955,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { status: "active", checks: 2, }); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0eb2dff9bfe3..9f1e34f8d1a0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -117,6 +117,7 @@ import { normalizeMcpText, } from "@t3tools/provider-core/server/mcpToolPresentation"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure, @@ -973,7 +974,7 @@ export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; const CLAUDE_T3_MCP_AUTHORIZATION_ENV = "T3_CODE_MCP_AUTHORIZATION"; export function claudeMcpQueryOverrides(input: { - readonly threadId: ThreadId; + readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; readonly readOnlySandbox: boolean; readonly allowedTools?: ReadonlyArray; }): { @@ -981,7 +982,7 @@ export function claudeMcpQueryOverrides(input: { readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; readonly mcpEnvironment?: Readonly>; } { - const session = McpProviderSession.readMcpProviderSession(input.threadId); + const session = input.mcpSession; if (session === undefined) { return input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }; } @@ -3026,9 +3027,10 @@ export interface ClaudeAdapterV2Options { }; } -export function makeClaudeAdapterV2( +export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( adapterOptions: ClaudeAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2["Service"] { +) { + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, @@ -7261,7 +7263,7 @@ export function makeClaudeAdapterV2( ) { const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy); const mcpOverrides = claudeMcpQueryOverrides({ - threadId: turnInput.threadId, + mcpSession: yield* mcpSessions.read(turnInput.threadId), readOnlySandbox: sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly", ...(queryPolicy.allowedTools === undefined @@ -8156,13 +8158,14 @@ export function makeClaudeAdapterV2( ), ), }); -} +}); export type ClaudeAdapterV2DriverEnv = | ClaudeAgentSdkQueryRunner | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | McpProviderSessions.McpProviderSessions | Path.Path | ServerConfig.ServerConfig; @@ -8186,7 +8189,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( expandHomePath(config.binaryPath), claudeEnvironment, ); - return makeClaudeAdapterV2({ + return yield* makeClaudeAdapterV2({ instanceId, settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, @@ -8234,7 +8237,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* const serverConfig = yield* ServerConfig.ServerConfig; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - return makeClaudeAdapterV2({ + return yield* makeClaudeAdapterV2({ instanceId: CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: hostEnvironment, @@ -8255,6 +8258,7 @@ const layer: Layer.Layer< | Crypto.Crypto | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 + | McpProviderSessions.McpProviderSessions | Path.Path | ServerConfig.ServerConfig > = Layer.effect(ProviderAdapter.ProviderAdapterV2, makeDefaultClaudeAdapterV2()); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index ef884b9c3abc..5b18f436c26e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -54,7 +54,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import packageJson from "../../../package.json" with { type: "json" }; import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as EffectWorker from "../EffectWorker.ts"; @@ -639,7 +639,7 @@ describe("CodexAdapterV2 runtime policy", () => { describe("CodexAdapterV2 process spawning", () => { it("injects cwd, model, and MCP authorization into thread-scoped params", () => { const threadId = ThreadId.make("thread-codex-mcp"); - McpProviderSession.setMcpProviderSession({ + const mcpSession = { environmentId: EnvironmentId.make("environment-codex-mcp"), threadId, providerSessionId: "mcp-session-codex", @@ -647,38 +647,34 @@ describe("CodexAdapterV2 process spawning", () => { endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer secret-codex-token", browserToolsAvailable: true, - }); + }; - try { - assert.deepEqual( - CodexAdapterV2.codexThreadRuntimeParams({ - threadId, - modelSelection: { model: "gpt-5.4" }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace/thread-codex-mcp", - }, - }), - { + assert.deepEqual( + CodexAdapterV2.codexThreadRuntimeParams({ + mcpSession, + modelSelection: { model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", cwd: "/workspace/thread-codex-mcp", - model: "gpt-5.4", - config: { - "tools.update_plan.enabled": true, - mcp_servers: { - "t3-code": { - url: "http://127.0.0.1:43123/mcp", - http_headers: { - Authorization: "Bearer secret-codex-token", - }, + }, + }), + { + cwd: "/workspace/thread-codex-mcp", + model: "gpt-5.4", + config: { + "tools.update_plan.enabled": true, + mcp_servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + http_headers: { + Authorization: "Bearer secret-codex-token", }, }, }, }, - ); - } finally { - McpProviderSession.clearMcpProviderSession(threadId); - } + }, + ); }); it.effect("resolves Windows command shims through the shared spawn policy", () => @@ -822,7 +818,9 @@ describe("CodexAdapterV2 process spawning", () => { assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]); }).pipe( - Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer)), + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), Effect.provideService(HostProcessPlatform, "linux"), ), ); @@ -1716,7 +1714,7 @@ describe("CodexAdapterV2 session initialize", () => { beforeEmitInbound === undefined ? {} : { beforeEmitInbound }, ); let initializeRequests = 0; - const adapter = CodexAdapterV2.makeCodexAdapterV2({ + const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, @@ -1811,7 +1809,12 @@ describe("CodexAdapterV2 session initialize", () => { providerThreads.map((providerThread) => providerThread.nativeThreadRef?.nativeId), ["concurrent-first", "concurrent-second"], ); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("retries initialize after a failed handshake", () => @@ -1837,7 +1840,12 @@ describe("CodexAdapterV2 session initialize", () => { const providerThread = yield* session.ensureThread("thread-initialize-retry"); assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-retry"); assert.equal(session.initializeRequests(), 2); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("completes the handshake after a caller is interrupted mid-initialize", () => @@ -1880,7 +1888,12 @@ describe("CodexAdapterV2 session initialize", () => { const providerThread = yield* session.ensureThread("thread-initialize-interrupted"); assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-interrupted"); assert.equal(session.initializeRequests(), 2); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); }); @@ -1940,7 +1953,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), }; - const adapter = CodexAdapterV2.makeCodexAdapterV2({ + const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, @@ -2121,7 +2134,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.equal(requests.filter((method) => method === "turn/start").length, 1); assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("identifies sessions to Codex with the same client info as main", () => @@ -2157,7 +2175,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, ]); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("unsubscribes from the native thread when it is unloaded", () => @@ -2195,7 +2218,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isDefined(harness.runtime.unloadThread); yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); assert.deepEqual(requests, ["initialize", "thread/start", "thread/unsubscribe"]); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("keeps the app-server failure as the cause when an unload is rejected", () => @@ -2229,7 +2257,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(error._tag, "ProviderAdapterProtocolError"); const cause = error._tag === "ProviderAdapterProtocolError" ? error.cause : undefined; assert.equal((cause as { _tag?: string } | undefined)?._tag, "CodexAppServerRequestError"); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("waits for native start before interrupting an acknowledged queued turn", () => @@ -2323,7 +2356,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); assert.lengthOf(harness.terminalEvents(), 1); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("settles Stop when a queued native turn fails before starting", () => @@ -2405,7 +2443,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.terminalEvents(), 1); assert.isFalse(interruptSent, "A terminal native turn must not receive turn/interrupt"); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("bounds Stop when a queued native turn never starts", () => @@ -2465,7 +2508,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.terminalEvents(), 1); assert.isFalse(interruptSent, "An unstarted native turn must not receive turn/interrupt"); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); it.effect("sends currency-sigil skill mentions to Codex as $ mentions", () => @@ -2524,7 +2572,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { text: "then £ship it", }, }); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); const assistantMessages = (events: ReadonlyArray) => @@ -2674,7 +2727,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); } assert.isEmpty(assistantMessages(harness.events)); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2748,7 +2805,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); const harness = yield* makeCodexReplayHarness(transcript); - McpProviderSession.setMcpProviderSession({ + yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("test"), threadId: harness.threadId, providerSessionId: "context-session", @@ -2757,9 +2814,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { authorizationHeader: "Bearer test", browserToolsAvailable: true, }); - yield* Effect.addFinalizer(() => - Effect.sync(() => McpProviderSession.clearMcpProviderSession(harness.threadId)), - ); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, @@ -2771,7 +2825,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); yield* harness.firstTerminal; assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2847,7 +2905,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.equal(items[0]?.id, items[1]?.id); assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -2895,7 +2957,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); assert.equal(resumed.status, "idle"); assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3050,7 +3116,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.equal(resumed.status, "idle"); assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3132,7 +3202,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { restartContinuationOfRunId: RunId.make("run-before-restart"), }); assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3280,7 +3354,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isAtLeast(recoveredIndex, 0); assert.isAbove(resumedCommandIndex, recoveredIndex); assert.isAbove(terminalIndex, resumedCommandIndex); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3379,7 +3457,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ["compaction", 1782622565000], ], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3543,7 +3625,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); } assert.deepEqual(assistantMessages(harness.events), []); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3672,7 +3758,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["CODEX_RECOVERY_OK"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3715,7 +3805,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { })), [{ text: "CODEX_RECOVERY_OK", streaming: false }], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3759,7 +3853,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["CODEX_RECOVERY_OK"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3878,7 +3976,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { new Set(assistantMessages(harness.events).map((event) => event.message.id)).size, 1, ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3906,7 +4008,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), [""], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3935,7 +4041,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), [""], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3964,7 +4074,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["Working on it.", ""], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -3993,7 +4107,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["CODEX_RECOVERY_OK"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4022,7 +4140,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["CODEX_RECOVERY_OK"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4051,7 +4173,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assistantMessages(harness.events).map((event) => event.message.text), ["", "CODEX_RECOVERY_OK"], ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4207,7 +4333,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.lengthOf(harness.terminalEvents(), 1); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -4362,7 +4492,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.terminalEvents(), 1); assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.lengthOf(harness.continuationRequests, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); }, ); @@ -4722,7 +4856,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.lengthOf(harness.continuationRequests, 0); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.terminalEvents(), 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5011,7 +5149,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5239,7 +5381,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5324,7 +5470,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5412,7 +5562,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5523,7 +5677,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5688,7 +5846,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -5953,7 +6115,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { commandUpdatesBeforeLateEvents.length, "late starts and completions must not project after timeout", ); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6060,7 +6226,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6296,7 +6466,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isDefined(item); } if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6394,7 +6568,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ), ); @@ -6555,7 +6733,11 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.include(stored, "connect-src https://api.weather.test"); assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("", @@ -271,6 +273,7 @@ describe("HtmlRender", () => { expect(preview.consoleMessages).toEqual( expect.arrayContaining([ { level: "log", text: "ready 3" }, + { level: "log", text: "focus 2px -2px 400 400" }, { level: "info", text: HTML_RENDER_MEASURE_FONTS.sans }, { level: "warning", diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index d34a450f6650..fdb50a6bb063 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -23,7 +23,7 @@ import { cn } from "~/lib/utils"; * span with tabIndex for a tooltip) gets the focus outline. */ const contextChipVariants = cva( - "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", + "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", { defaultVariants: { kind: "neutral" }, variants: { @@ -117,7 +117,7 @@ function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) { const defaultProps = { className: cn( - "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", + "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", className, ), "data-slot": "context-chip-action", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e3aff08484e..5615c64ee450 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -793,9 +793,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { value={option.value} className={cn( "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", isSelected - ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > @@ -817,7 +817,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { > Repository -
+
{publishHost}/ @@ -879,9 +879,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { value={option.value} className={cn( "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", isSelected - ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b78c6f955e2b..bff23a3a365e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -246,7 +246,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); @@ -769,7 +769,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP target="_blank" rel="noopener noreferrer" aria-label={prStatus.tooltip} - className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} + className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring`} onPointerDown={(event) => event.stopPropagation()} onClick={handlePrClick} > @@ -835,7 +835,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22ce..3e95f86de902 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1817,7 +1817,7 @@ function TimelineMinimap({ /> diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 9bf65102bf70..dd8cd0dab03f 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -203,7 +203,7 @@ export function MediaVideoPlayer({ type="button" aria-label={label ? `Play ${label}` : "Play video"} onClick={onOpen} - className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 39af63a90616..afba72c805bd 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -44,7 +44,7 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro type="button" aria-label={`Remove ${label} from history`} onClick={onRemove} - className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" + className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring group-hover:opacity-100" > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 19a246cf4be4..6db7e296337b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -369,7 +369,7 @@ export function ProjectScriptEditorDialog({ type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ isSelected - ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} onClick={() => { diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx index bcfb3b180113..48f71d139675 100644 --- a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -32,7 +32,7 @@ export function PullRequestCopyableCode({ diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index cb6f09af1001..cf4e91fcf534 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -46,7 +46,7 @@ export function FoldedSettingsSection({ className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" >

- + {title} }>
- + @@ -818,7 +818,7 @@ function KeybindingKeyControl({ onClick={() => setDraft({ isRecording: true })} aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`} className={cn( - "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/24", + "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24", pillClassName, )} > diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx index 34178f9b54c7..9bc2478923b1 100644 --- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx +++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx @@ -11,7 +11,7 @@ export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) { + } + /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" · ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9..a47a4bf00241 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..aff52adeb155 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 000000000000..306302db6761 --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..c92226a9ee4f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f2..c11a49897b88 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b738..b2c6933fabb7 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..1d49fd71f70c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..c84169e6e33f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..2a61480a907b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c..36afee2f9efa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d9658..b5d39323a23d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fd..0038ede86c2d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..af9a7112c3ea 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74f..dbc2704171f9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c1..909eeb0a35ce 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, From e858ddb65668d54be5de3996f30c0bd5c3149657 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 10 Oct 2026 03:09:23 +0100 Subject: [PATCH 124/377] test(usage): usage service tests keep their state directory until cache writes land (#17636) --- apps/server/src/usage/UsageService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 393437fcc37b..e28e926d3f89 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -132,7 +132,9 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -1203,8 +1205,7 @@ describe("UsageService", () => { return text; }); - const restarted = yield* UsageService.make; - const summary = yield* restarted.readSummary(WINDOW); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1217,9 +1218,6 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); - // The migrated cache is written in the background; let it land before - // the layer removes the state directory under it. - yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ From f3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 9 Oct 2026 19:09:46 -0700 Subject: [PATCH 125/377] fix(checkpoint): pulls and rebases no longer flood a turn's changed files (#17161) Co-authored-by: Dylan Zahn Co-authored-by: Claude Opus 5.5 --- .../checkpointing/CheckpointDiffQuery.test.ts | 30 ++++ .../src/checkpointing/CheckpointDiffQuery.ts | 68 +++++++-- .../src/checkpointing/CheckpointStore.test.ts | 132 +++++++++++++++++- .../src/checkpointing/CheckpointStore.ts | 29 ++++ apps/server/src/checkpointing/Diffs.test.ts | 10 +- apps/server/src/checkpointing/Diffs.ts | 20 +++ .../CheckpointCaptureService.test.ts | 1 + .../CheckpointScopeOwnership.test.ts | 1 + .../src/orchestration-v2/CheckpointService.ts | 54 ++++--- apps/server/src/vcs/GitVcsDriver.ts | 64 ++++++++- apps/server/src/vcs/VcsDriver.ts | 11 ++ docs/user/source-control.md | 7 + 12 files changed, 390 insertions(+), 37 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index b7cf965f57ca..bdb3e02785d8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext { function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; + readonly authoredPaths?: ReadonlySet; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( @@ -57,6 +58,7 @@ function layerFor(input: { }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), + listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null), }), ), ), @@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { }).pipe(Effect.provide(layer)); }); +it.effect("leaves Git imports out of large turn diffs", () => { + const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed( + input.format === "numstat" + ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") + : (input.filePaths ?? ["all"]).join("\n"), + ), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + authoredPaths: new Set(ownPaths), + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + const patchPaths = diffCheckpoints.mock.calls + .map(([call]) => call) + .filter((call) => call.format !== "numstat") + .map((call) => call.filePaths ?? ["all"]); + assert.isAbove(patchPaths.length, 1); + assert.sameMembers(patchPaths.flat(), ownPaths); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the typed missing-thread error contract", () => { const layer = layerFor({ projection: Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 37414f35bfb6..37ba07ca12c3 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -31,6 +31,31 @@ import { type CheckpointServiceError, } from "./Errors.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts"; + +// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. +const MAX_PATHSPEC_CHARS = 24_000; + +/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */ +function batchFilePaths(files: ReadonlyArray) { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path]; + // Each pathspec also carries a `:(top,literal)` prefix and a separator. + const size = paths.reduce((total, path) => total + path.length + 16, 0); + if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(...paths); + length += size; + } + if (batch.length > 0) batches.push(batch); + return batches; +} /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { }); } - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + const comparison = { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary. + // Filtering is optional: when it cannot run, the complete diff still loads. + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const diff = + retainedFiles.length === files.length + ? yield* checkpointStore + .diffCheckpoints(comparison) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")) + : (yield* Effect.forEach( + batchFilePaths(retainedFiles), + (filePaths) => + checkpointStore + .diffCheckpoints({ ...comparison, filePaths }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")), + { concurrency: 4 }, + )).join(""); const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 2846b86e1b88..b345535fade7 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -57,15 +57,22 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, + options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, ): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const result = yield* process.run({ + const vcsProcess = yield* VcsProcess.VcsProcess; + const result = yield* vcsProcess.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, + ...(options.committedAt === undefined + ? {} + : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), }); return result.stdout.trim(); }); @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, - { path: "copied.txt", additions: 0, deletions: 0 }, + { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, - { path: renamedPath, additions: 1, deletions: 1 }, + { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); @@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ]); }), ); + it.effect("limits a diff to repository paths from a subdirectory workspace", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = NodePath.join(tmp, "app"); + yield* fileSystem.makeDirectory(cwd); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + const patch = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + filePaths: ["app/kept.txt"], + }); + expect(patch).toContain("+kept"); + expect(patch).not.toContain("hidden"); + }), + ); + }); + + describe("listAuthoredPaths", () => { + it.effect("leaves out paths that only a merged upstream changed", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string, contents: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), contents); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt", "upstream\n"); + yield* commitBeforeTurn("shared.txt", "upstream\n"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("shared.txt", "ours\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); + + // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. + yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); + yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); + yield* git(tmp, ["commit", "-am", "merge upstream"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + const numstat = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + format: "numstat", + }); + expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ + "README.md", + "shared.txt", + "upstream.txt", + ]); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["README.md", "shared.txt"]), + ); + }), + ); + + it.effect("leaves out paths that only a rebase onto upstream brought in", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("feature.txt"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* git(tmp, ["rebase", "upstream"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a65..78844ec70dfe 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -40,6 +40,14 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface ListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface DeleteCheckpointRefsInput { @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * List paths changed by work done after the "from" checkpoint: uncommitted + * edits at either checkpoint, commits made after "from", and commits that + * left HEAD. Commits a pull, merge, or rebase brought in are older than + * "from", so their paths are not listed. + * + * Returns null when HEAD did not move or a checkpoint does not record HEAD. + * Then every changed path belongs to the turn. + */ + readonly listAuthoredPaths: ( + input: ListAuthoredPathsInput, + ) => Effect.Effect | null, CheckpointStoreError>; + /** * Delete the provided checkpoint refs. * @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.diffCheckpoints(input); }); + const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( + "listAuthoredPaths", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); + return yield* checkpoints.listAuthoredPaths(input); + }); + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + listAuthoredPaths, deleteCheckpointRefs, }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 6b7f1a875690..441643fbda63 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { ]); }); - it("uses destination paths for renames and copies", () => { + it("preserves both paths for renames and copies", () => { const numstat = [ "0\t0\t", "src/old.ts", @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "other.ts", additions: 1, deletions: 0 }, - { path: "src/copied.ts", additions: 2, deletions: 1 }, - { path: "src/new.ts", additions: 0, deletions: 0 }, + { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 }, + { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 }, ]); }); @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { const path = " café\tline\r\nname.txt "; const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; - expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); + expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ + { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 }, + ]); expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ { path, additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index b7951009118e..24aa3b6879e9 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,5 +1,6 @@ export interface TurnDiffFileSummary { readonly path: string; + readonly previousPath?: string; readonly additions: number; readonly deletions: number; } @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray left.path.localeCompare(right.path)); } + +/** + * True when Git brought this file in and no work in the range touched it. + * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported. + * A rename stays visible when either side was authored, because the summary shows it as one file. + */ +export function isGitImport( + file: TurnDiffFileSummary, + authoredPaths: ReadonlySet | null, +): boolean { + return ( + authoredPaths !== null && + !authoredPaths.has(file.path) && + (file.previousPath === undefined || !authoredPaths.has(file.previousPath)) + ); +} diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index bb7619f6a8ab..9b24161b54e3 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { IdAllocator.layer, NodeCrypto.layer, Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, hasCheckpointRef: () => diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index b29eb33b69a2..33a7490a0245 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o getCheckpointContext: () => Effect.succeed(context), }), Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), diffCheckpoints: (input) => { assert.equal(input.cwd, "/prepared-repo"); assert.equal(input.fromCheckpointRef, checkpointRefs[0]); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c..ee3316311eac 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -440,33 +440,49 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); + const refs = { + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + // Keep only the files the turn's own work touched. const files = previousExists - ? yield* checkpointStore - .diffCheckpoints({ - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, fallbackFromToHead: false, ignoreWhitespace: false, format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), + }), + checkpointStore.listAuthoredPaths(refs).pipe( Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { scopeId: input.scope.id, checkpointRef, cause: String(cause), - }).pipe(Effect.as([])), + }).pipe(Effect.as(null)), ), - ) + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ) : []; return makeCheckpoint({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..599269791edf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const message = `t3 checkpoint ref=${input.checkpointRef}`; + // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, - args: [...durableWrite, "commit-tree", treeOid, "-m", message], + args: [ + ...durableWrite, + "commit-tree", + treeOid, + ...(headExists ? ["-p", "HEAD"] : []), + "-m", + message, + ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, + // Paths are repository-relative, while cwd can be a subdirectory. + ...(input.filePaths + ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] + : []), ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return result.stdout; }), + listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { + const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const heads = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "log", + "--no-walk=unsorted", + "--format=%P %ct", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + }); + // Each line is " ". The parent is the HEAD at capture. + const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); + const [startHead = "", capturedAt = ""] = fromLine.split(" "); + const [endHead = ""] = toLine.split(" "); + if (startHead === "" || endHead === "" || startHead === endHead) { + return null; + } + + const listPaths = (args: ReadonlyArray) => + execute({ + operation, + cwd: input.cwd, + args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\0"))); + // Remerge diff lists a merge's paths only where the result differs from Git's + // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + const pathLists = yield* Effect.all( + [ + listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), + listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), + listPaths([ + "log", + "--format=", + "--diff-merges=remerge", + `--since=@${capturedAt}`, + `${startHead}..${endHead}`, + ]), + ], + { concurrency: "unbounded" }, + ); + return new Set(pathLists.flat().filter((path) => path.length > 0)); + }), + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..c124aa90b963 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface VcsListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface VcsDeleteCheckpointRefsInput { @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { input: VcsRestoreCheckpointInput, ) => Effect.Effect; readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly listAuthoredPaths: ( + input: VcsListAuthoredPathsInput, + ) => Effect.Effect | null, VcsError>; readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b9..3730eb6a18d4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -3,6 +3,13 @@ T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. +## Review turn changes + +A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or +rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, +committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed +against your base branch. Restore still returns the complete saved workspace. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote From 07b80800124982bce94879a97767a5365987e8f1 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 10 Oct 2026 07:45:23 +0530 Subject: [PATCH 126/377] fix(web): place notification icons after titles (#12209) --- apps/web/src/components/ui/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 27ff2a2733a9..9448bfff99ea 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -340,6 +340,7 @@ function ToastBodyContent({ )} >
+ {leadingIcon ? (
) : null} -
Date: Fri, 9 Oct 2026 22:17:46 -0400 Subject: [PATCH 127/377] fix(web): attachments on an open question are visible again (#15537) --- apps/web/src/components/chat/ChatComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9ef3b1d81cf4..0d55c1c83e06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7094,7 +7094,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && - pendingUserInputs.length === 0 && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || expandedComposerImages.length > 0) && ( From f34adad34f21510e867b47fe018c7975779a0ffc Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:18:58 +0500 Subject: [PATCH 128/377] fix(web): scale Files tree with interface font size (#8011) --- apps/web/src/pierre-tree-theme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 906b34ba1c51..3f191d255789 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -8,7 +8,7 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; + --trees-font-size-override: 0.75rem; } button[data-type='item'] { border-radius: 5px; } svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } From 318201caa520525517ef2bee8f4bbeedb2fec1d7 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:29 -0300 Subject: [PATCH 129/377] fix(server): probe only owned preview listeners (#16687) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/preview/PortScanner.test.ts | 163 +++++++++----------- apps/server/src/preview/PortScanner.ts | 79 ++++------ 2 files changed, 110 insertions(+), 132 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d4699..8aae21d8cf71 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); @@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e966333..7a59079ea9f4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( From ed4ea1083d477b67304f50d5691ba5fc2b9496cd Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:57 -0300 Subject: [PATCH 130/377] fix(chat): surface pending subagent questions on parents (#16634) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++- .../threads/floating-working-control.tsx | 24 ++++++++-- .../threads/floating-working-status.ts | 6 +++ apps/mobile/src/state/entities.ts | 4 ++ apps/web/src/components/ChatView.tsx | 47 +++++++++++++++++-- apps/web/src/state/entities.ts | 6 +++ .../client-runtime/src/state/threadShell.ts | 46 ++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da..a3d7a6f4e0c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.id, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f21a83f09a17..1fb87ce1c725 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 0d3b33d582d5..b8e42b0cb99e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a80fc56c57e6..06a6bacaf48c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -427,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..ebf62f01be6f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index bc6d444f471a..3cb54087ac14 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: EnvironmentThreadShell[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push(scopedThread(ref.environmentId, child)); + } + } + } + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } From c1f21e53b3b138cc591f9df90bd5c8aef6a00ce4 Mon Sep 17 00:00:00 2001 From: Bishal Banerjee <56464152+ZenderGoD@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:09:30 +0530 Subject: [PATCH 131/377] fix(web): section header chevrons point up when collapsed (#14273) Co-authored-by: zendergod Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ui/collapsible-section-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 5cc52ffe2950..6a685fe03acd 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -36,7 +36,7 @@ export function CollapsibleSectionHeader({ {accessory} ); From e08c31e1a1fd5e89e2d4130b9ad18b44d15f92cd Mon Sep 17 00:00:00 2001 From: jonesfionn101-dotcom Date: Sat, 10 Oct 2026 03:39:40 +0100 Subject: [PATCH 132/377] fix(web): timeline divider pill shows a pointer, visible hover and focus ring (#15188) Co-authored-by: Fionn Jones <264670603+jonesfionn101-dotcom@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/TimelineSystemDivider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index 1c4be795007c..e680cab50c4c 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -48,7 +48,7 @@ export function TimelineSystemDivider(props: { type="button" aria-label={props.actionLabel} onClick={props.onAction} - className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-muted" + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" /> } > From 6e4ca6ce9d04009af290412b77a0acb9be9f8a18 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:48:05 -0300 Subject: [PATCH 133/377] fix(git): allow creating prs from dirty worktrees (#15625) --- apps/server/src/git/GitManager.test.ts | 32 +++++++++++++++++-- apps/server/src/git/GitManager.ts | 8 ----- .../GitActionsControl.logic.test.ts | 12 +++++-- .../src/components/GitActionsControl.logic.ts | 1 - apps/web/src/components/GitActionsControl.tsx | 5 +-- .../client-runtime/src/state/gitActions.ts | 8 ++--- 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a133..c9a08607bc08 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect( + (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(), + ).toBe(headBefore); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), @@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; + const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout; + let generatedContent: TextGeneration.PrContentGenerationInput | undefined; const { manager } = yield* makeManager({ + textGeneration: { + generatePrContent: (input) => { + generatedContent = input; + return Effect.succeed({ title: "PR only branch", body: "Committed feature work" }); + }, + }, ghScenario: { prListSequence: [ JSON.stringify([]), @@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); + expect(generatedContent?.diffPatch).toContain("+pr only"); + expect(generatedContent?.diffPatch).not.toContain("staged readme"); + expect(generatedContent?.diffPatch).not.toContain("unstaged feature work"); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore); expect( events.filter( (event): event is Extract => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a9..bd4ee0da7e9d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () { detail: "Feature-branch checkout is only supported for commit actions.", }); } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Commit local changes before creating a PR.", - }); - } - const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6d33d412ec8c..b29a44be3702 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { + it("enables create PR when synced with upstream but ahead of default, even with local changes", () => { const syncedFeature = status({ aheadCount: 0, behindCount: 0, @@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => { const items = buildMenuItems(syncedFeature, false); assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + + const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true }; + assert.equal( + buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, + false, + ); }); it("resolveQuickAction returns disabled no-action state", () => { @@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => { ]); }); - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { const items = buildMenuItems( status({ refName: "feature/test", @@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => { { id: "pr", label: "Create PR", - disabled: true, + disabled: false, icon: "pr", kind: "open_dialog", dialogAction: "create_pr", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8d3b7bc30578..5422628825a6 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -184,7 +184,6 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && hasDefaultBranchDelta && !isBehind && diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5615c64ee450..cfe45ff24cc4 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -338,13 +338,10 @@ function getMenuActionDisabledReason({ if (!hasBranch) { return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e6..f708e9ca9b98 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -65,9 +65,8 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: { if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return "No local commits to include in a PR."; } if (isBehind) { From b822420e5074d750e7258ea48568666720cf0415 Mon Sep 17 00:00:00 2001 From: Ege <68082325+ege-arhan@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:57:13 +0300 Subject: [PATCH 134/377] docs(install): polish binary install destination phrasing (#15732) --- docs/user/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/install.md b/docs/user/install.md index 948e5d367607..67df94217ac3 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -20,7 +20,7 @@ On Windows, in PowerShell: irm https://t3.codes/install.ps1 | iex ``` -This puts `t3` in `~/.local/bin`. If your shell reports `command not found` +This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found` afterwards, that directory is not on your `PATH` yet; the installer prints the line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or `T3CODE_VERSION` to pin an exact version. From 0981e6b9d421ec46010d9b9edebd0beb241ef472 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:57:34 +0200 Subject: [PATCH 135/377] perf(server): keep passive terminal output flowing (#17178) --- apps/server/src/terminal/OutputProtocol.test.ts | 2 ++ apps/server/src/terminal/OutputProtocol.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 548a09c08686..a12f9d6ecbf8 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -13,8 +13,10 @@ import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalObserve, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.terminalObserve, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => Effect.gen(function* () { diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 441961a7952f..402ed32c022a 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -22,6 +22,7 @@ export function withTerminalOutputWindow( if ( message._tag === "Request" && (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.terminalObserve || message.tag === WS_METHODS.subscribeTerminalEvents) ) { const key = `${clientId}:${message.id}`; From b6aa268b12e8eb6d5aab52e5e09c12e255fca905 Mon Sep 17 00:00:00 2001 From: Anneshu Nag <132702983+NK-Works@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:28:01 +0530 Subject: [PATCH 136/377] fix(web): stop button icon no longer shifts on hover (#16012) --- apps/web/src/components/chat/ComposerPrimaryActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ac8a70e66553..85d8504ce256 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -138,7 +138,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } /> - {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + {pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel} ) : primaryAction === "auto-merge-armed" ? ( @@ -1970,21 +1962,44 @@ export function PullRequestDetailPanel({ size="xs" variant="default" disabled={actionPending} - onClick={() => setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} aria-label={ - pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel + pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel } > - - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + + {selectedMergeMethodLabel} + + + Confirm merge? + + + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} + } /> - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} ) : (primaryAction === "merged" || primaryAction === "closed") && @@ -2101,11 +2116,18 @@ export function PullRequestDetailPanel({ ) : null} {showsMergeNow ? ( setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} > - Merge now + {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} ) : null} {/* The same merge, left with the host to carry out once its requirements @@ -2121,13 +2143,22 @@ export function PullRequestDetailPanel({ ) : showsAutoMerge ? ( - setConfirmation({ open: true, action: "enable-auto-merge" }) + closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} + aria-label={ + inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge" } + disabled={actionPending} + {...inlineConfirm.props( + "enable-auto-merge", + () => void perform("enable-auto-merge", selectedMergeMethod), + )} > - Enable auto-merge + {inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge"} ) : null} {/* A preference for the merge action rather than a second action, so it @@ -2200,12 +2231,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "close" })} + {...inlineConfirm.props("close", () => void perform("close"))} > - Close pull request + {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} ) : detail.state === "closed" && can("reopen") ? ( @@ -2220,11 +2255,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "revert" })} + {...inlineConfirm.props("revert", () => void perform("revert"))} > - Revert changes + {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} ) : null} @@ -2553,20 +2593,33 @@ export function PullRequestDetailPanel({ size="xs" variant="warning-outline" disabled={actionPending} - onClick={() => - setConfirmation({ open: true, action: "approve-workflows" }) - } + {...inlineConfirm.props( + "approve-workflows", + () => void perform("approve-workflows"), + )} aria-label={ pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run" + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run" } > - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} + + + Approve workflows to run + + + Confirm approve? + + + {pendingAction === "approve-workflows" + ? "Approving..." + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} + @@ -2575,7 +2628,9 @@ export function PullRequestDetailPanel({ {pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run"} + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} ) : ( @@ -2802,74 +2857,6 @@ export function PullRequestDetailPanel({ />
) : null} - - setConfirmation((current) => ({ ...current, open }))} - onOpenChangeComplete={(open) => { - if (!open) setConfirmation({ open: false, action: "merge" }); - }} - > - - - - {confirmAction === "merge" - ? "Merge pull request?" - : confirmAction === "enable-auto-merge" - ? "Enable auto-merge?" - : confirmAction === "revert" - ? "Revert these changes?" - : confirmAction === "approve-workflows" - ? "Approve workflows to run?" - : "Close pull request?"} - - - {confirmAction === "merge" - ? `This merges #${reference.number} using ${selectedMergeMethod}.` - : confirmAction === "enable-auto-merge" - ? // The host merges this as soon as it considers the pull request ready, which - // may be immediately — there is no telling from here whether anything is - // still outstanding. - `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` - : confirmAction === "revert" - ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` - : confirmAction === "approve-workflows" - ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` - : `This closes #${reference.number} without merging it.`} - - - - }> - Cancel - - - - -

); } diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.ts b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts new file mode 100644 index 000000000000..5b4f7fc57212 --- /dev/null +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts @@ -0,0 +1,55 @@ +import { useEffect, useState, type MouseEvent } from "react"; + +/** Arms one control at a time, including controls rendered in a menu portal. */ +export function useInlineActionConfirm(scopeKey: string) { + const [armed, setArmed] = useState<{ + scopeKey: string; + action: string; + element: HTMLElement; + } | null>(null); + const active = armed?.scopeKey === scopeKey ? armed : null; + + useEffect(() => { + if (!armed) return; + const disarm = () => setArmed(null); + if (armed.scopeKey !== scopeKey) { + disarm(); + return; + } + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !armed.element.contains(event.target)) disarm(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + disarm(); + } + }; + const timeout = window.setTimeout(disarm, 3000); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", disarm); + return () => { + window.clearTimeout(timeout); + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", disarm); + }; + }, [armed, scopeKey]); + + return { + isArmed: (action: string) => active?.action === action, + props: (action: string, onConfirm: () => void) => ({ + onClick: (event: MouseEvent) => { + if (active?.action === action && active.element === event.currentTarget) { + setArmed(null); + onConfirm(); + } else { + setArmed({ scopeKey, action, element: event.currentTarget }); + } + }, + onBlur: () => setArmed(null), + }), + }; +} From 4107930420cffe14776ad80783f7060236b5868b Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:39 -0700 Subject: [PATCH 167/377] feat(web): reorder right panel tabs by dragging (#17730) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 330 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 5 files changed, 260 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06a6bacaf48c..2aeca85e6aa2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} @@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1206929b4c..5c8323a7ae34 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,6 +14,17 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, @@ -27,6 +38,7 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -115,6 +127,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -199,6 +213,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
+ ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
- {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
- ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.open !== false ? (
{ ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 812de8804046..8afdbedfcc87 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -176,6 +176,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: ( ref: ScopedThreadRef, tabIds: readonly string[], @@ -893,22 +894,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + updateThread(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee257..02e0e9e26d46 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2241,6 +2241,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} From c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:39:17 -0300 Subject: [PATCH 168/377] fix(azure-devops): list pull requests with token sign-in and check out into worktrees (#17725) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 100 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 45 +++++++- .../src/server/AzureDevOpsCli.test.ts | 25 +++++ .../server/AzureDevOpsPullRequestCli.test.ts | 43 +++++++- .../src/server/AzureDevOpsPullRequestCli.ts | 37 ++++++- .../AzureDevOpsSourceControlProvider.test.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 3 +- .../src/server/azureDevOpsPullRequests.ts | 4 + 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c9a08607bc08..79053fbb65eb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("prepares a worktree from the head branch when the host has no pull ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n"); + yield* runGit(repoDir, ["add", "head.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]); + yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]); + // The remote keeps its Azure spelling, which is what the pull request is compared with. + const remoteUrl = "https://dev.azure.com/org/project/_git/repo"; + yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]); + yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]); + + // A pull request of another repository in the organization is not this remote's branch. + const { manager: otherRepositoryManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Another repository's PR", + url: "https://dev.azure.com/org/project/_git/other/pullrequest/79", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(otherRepositoryManager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }), + ); + // Nor is the branch of a closed one, which may have moved past the head it closed with. + const { manager: mergedManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Merged PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "merged", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(mergedManager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }), + ); + const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout; + expect(localBranches).not.toContain("feature/no-pull-ref"); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Azure DevOps PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/no-pull-ref"); + const worktreeHead = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "HEAD", + ])).stdout.trim(); + expect(worktreeHead).toBe(headSha); + const upstream = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstream).toBe("origin/feature/no-pull-ref"); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bd4ee0da7e9d..c5d372f8fb2b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -54,6 +54,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { + canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); + yield* gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the + // same repository is a branch on the primary remote, so it is fetched by name instead, + // but only when that remote is the pull request's own repository: Azure finds a pull + // request by number anywhere in the organization. Only while it is open, too: the + // branch of a closed one may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue( + cwd, + `remote.${remoteName}.url`, + ); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + canonicalRepositoryKey(pullRequestKey) !== + canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index e4fca9c5ffae..0a6b4fd6fc57 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", timeoutMs: 30_000, }); + assert.strictEqual(result.isCrossRepository, false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks a pull request from a fork as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 43, + title: "From a fork", + sourceRefName: "refs/heads/main", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" }); + + assert.strictEqual(result.isCrossRepository, true); }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index b3afaca03715..8ad71188e187 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("fails when nobody is signed in", () => + it.effect("names a token sign-in by the viewer's own pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("Please run 'az login' to setup account."), + }); + mockedExecute + .mockReturnValueOnce(Effect.fail(notLoggedIn)) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))) + .mockReturnValueOnce(Effect.succeed(output("[]"))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); + expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); + // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), ); + it.effect("fails when no sign-in can read pull requests", () => + Effect.gen(function* () { + const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("signed out"), + }); + mockedExecute.mockReturnValue(Effect.fail(signedOut)); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError"); + }), + ); + it.effect("completes a pull request to merge it, squashing only when asked", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 70fe78b34bf9..b7d719725fe3 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError error._tag !== "AzureDevOpsCliUnavailableError", + () => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--creator", + "me", + "--status", + "all", + "--top", + "1", + ], + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodePullRequestListJson(result.stdout.trim() || "[]"); + const login = Result.isSuccess(decoded) + ? decoded.success.items[0]?.author?.login + : undefined; + return login === undefined + ? Effect.fail( + new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }), + ) + : Effect.succeed(login); + }), + ), + ), ), listPullRequests: (input) => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224..f0362851596d 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "closed", + isCrossRepository: false, closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index dcb07e0632f3..f935fb808709 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; @@ -77,7 +78,7 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 6179a61aa65d..7ffc398490cf 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), + /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( headRefName: normalizeRefName(raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), + isCrossRepository: raw.forkSource != null, closedAt: state === "closed" ? terminalAt : null, mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( From 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:47:53 -0300 Subject: [PATCH 169/377] fix(usage): keep one email in two workspaces as two accounts (#17711) --- apps/server/src/provider/ClaudeProvider.ts | 6 + apps/server/src/provider/CodexProvider.ts | 12 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../server/src/provider/claudeResetCredits.ts | 7 + apps/server/src/usage/cliproxyApi.ts | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 6 + .../src/server/managedProvider.test.ts | 16 +- .../src/server/managedProvider.ts | 27 ++- packages/shared/src/usageLimits.test.ts | 190 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 165 ++++++++++----- 11 files changed, 382 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fef..8bc961834f27 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + /** + * The organization UUID from the login's `.claude.json`. The driver reads it + * in the same cached probe, so it describes the same login as the rest. + */ + readonly workspaceId?: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** Where the CLI found an API key, when it authenticates with one. */ @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01..5109bb840f56 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; + /** The ChatGPT workspace the usage belongs to, when the backend names it. */ + readonly accountId?: string | null | undefined; } | { readonly failure: string }; @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); + // One email can hold several workspaces, each with its own quota; the usage + // read names the one this login draws on. + const workspaceId = + rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); + : accountProbeStatus(snapshot); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df28..0169eb87ba76 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.all([ + probeClaudeCapabilities(effectiveConfig, processEnv, cwd), + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath), + ]).pipe( + Effect.map(([capabilities, workspaceId]) => + capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities, + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e381fa7cabe2..3c4a54742082 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ +/** The organization the login is signed in to; one email can belong to several. */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( configDir: string | undefined, ) { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index c50ebb57f40e..b80ebe59261e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519..0108bbb669eb 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), + /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d1..a726f05a311c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The workspace or organization whose quota the login draws on, such as a + * ChatGPT workspace or Claude organization id. One email can belong to + * several, each with its own quota. + */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c..1d73e112b815 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( Effect.map((count) => count === 1 - ? { ...refreshedSnapshot, usageLimits: probedLimits } + ? { + ...refreshedSnapshot, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + usageLimits: probedLimits, + } : { ...refreshedSnapshotSecond, + ...(count === 3 + ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } + : {}), usageLimits: { checkedAt: "2026-04-10T00:00:03.000Z", windows: [], @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + // ...and the workspace they were read for. + assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + + // A failed read for another workspace does not inherit those windows. + const switched = yield* provider.refresh; + assert.strictEqual(switched.auth.workspaceId, "ws-b"); + assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bad..a11734a1661b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; + // A failed read keeps the last good limits only for the same account, + // and they keep the workspace they were read for. + const previous = state.snapshot.auth; + const differs = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + // Limits read without a workspace cannot be attributed to a new one. + const switchedAccount = + differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || + (probedSnapshot.auth.workspaceId !== undefined && + probedSnapshot.auth.workspaceId !== previous.workspaceId); + const usageLimits = switchedAccount + ? probedSnapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); + const workspaceId = + usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; const snapshot = withUsageLimits( - probedSnapshot, - resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }), + workspaceId && !probedSnapshot.auth.workspaceId + ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } + : probedSnapshot, + usageLimits, ); return [ { snapshot, generation }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b2bef55cb108..668103302417 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -496,6 +496,128 @@ describe("pools", () => { expect(account?.redeem?.environmentId).toBe("env-2"); }); + it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => { + const login = (instanceId: string, workspaceId: string, usedPercent: number) => + provider({ + instanceId: ProviderInstanceId.make(instanceId), + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] }, + }); + const hubAccount = (id: string, workspaceId?: string) => ({ + id, + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], + usageLimitSources: [ + // A hub that names the workspace joins it; one that does not + // cannot say which of the two it read, so it keeps its own row. + { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [login("personal", "ws-plus", 26)] }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual([ + "env-a:business", + "env-a:personal", + "hub:x.json", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); + // One hub file name in two workspaces stays two accounts, even without an + // email to tell them apart. + const withoutEmail = (id: string, workspaceId: string) => { + const { email: _email, ...account } = hubAccount(id, workspaceId); + return account; + }; + const sameFile = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }], + }, + }, + ], + ]); + expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([ + "hub:a.json:ws-1", + "hub:a.json:ws-2", + ]); + expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("joins a report without a workspace to the only workspace signed in with its email", () => { + const native = provider({ + driver: claude, + auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = { + id: "claude.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }; + const withOther = (other: ServerProvider) => + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, other], + usageLimitSources: [{ ...source, accounts: [hubAccount] }], + }, + }, + ], + ]); + const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); + expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ + "env-a:codex", + ]); + // A second org whose limits could not be read still makes the hub ambiguous. + const unreadable = provider({ + ...native, + instanceId: ProviderInstanceId.make("team"), + auth: { ...native.auth, workspaceId: "org-b" }, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ + "env-a:codex", + "hub:claude.json", + ]); + }); + it("names an environment once however many of its instances share the account", () => { const shared = provider({ auth: { status: "authenticated", email: "same@example.com" }, @@ -536,6 +658,45 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("pools Codex windows by kind, whichever slot reported them", () => { + const codexAccount = (id: string, slots: readonly string[]) => ({ + id, + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt, + windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })), + }, + }); + const pooled = (accounts: ReturnType[]) => + collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } }, + ], + ]), + ), + now, + )[0]?.windows ?? []; + // A weekly-only plan reports weekly as `primary`. The pool keeps one id + // whichever account sorts first. + for (const order of [ + [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], + [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], + ]) { + const windows = pooled(order); + expect(windows.map((window) => window.id)).toEqual(["weekly"]); + expect(windows[0]?.members).toHaveLength(2); + } + // A second window of the same kind on one account stays apart, with its own id. + expect( + pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( + (window) => window.id, + ), + ).toEqual(["weekly", "weekly:secondary"]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { }); }); + it("gives a hub credit to no native row when its email is signed in to two workspaces", () => { + const workspaces = ["ws-a", "ws-b"].map((workspaceId) => + provider({ + instanceId: ProviderInstanceId.make(workspaceId), + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + }), + ); + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "same@example.com", + usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now); + expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([ + { instanceId: "ws-a" }, + { instanceId: "ws-b" }, + { sourceId: "hub", accountId: "duplicate", creditId: "c" }, + ]); + }); + it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { const fresher = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d..85c5e6e39f88 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -125,11 +125,51 @@ function accountKey( : null; } +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly workspaceId?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const nativeReport = (provider: ServerProvider): AccountReport => ({ + driver: provider.driver, + email: provider.auth.email, + workspaceId: provider.auth.workspaceId, + usageLimits: provider.usageLimits, +}); + +/** + * Keys each report by the account it draws on. One email can belong to + * several workspaces, each with its own quota, so a report that names its + * workspace is keyed by it too. One that does not (a hub, an older server) + * joins the only workspace known for its email, and gets no key when there + * are several, since it cannot say which one it read. Pass every enabled + * login, read or not, so an unreadable workspace still counts as one. + */ +function accountKeyResolver(reports: Iterable) { + const workspaces = new Map>(); + for (const report of reports) { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (key && report.workspaceId) { + workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); + } + } + return (report: AccountReport): string | null => { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (!key) return null; + if (report.workspaceId) return `${key}:${report.workspaceId}`; + const known = workspaces.get(key); + if (!known) return key; + return known.size === 1 ? `${key}:${[...known][0]}` : null; + }; +} + /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Matching emails or credentials across environments name one + * account, unless they name different workspaces. Its quota is one bucket, so + * counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + const resolveKey = accountKeyResolver( + [...presentations.values()].flatMap((presentation) => [ + ...(presentation.serverConfig?.providers ?? []) + .filter((provider) => provider.enabled) + .map(nativeReport), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]), + ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; - merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, - { - key: `${environmentId}:${provider.instanceId}`, - driver: provider.driver, - displayName: provider.displayName?.trim() || null, - email: provider.auth.email, - plan: provider.auth.label, - accentColor: provider.accentColor, - environments: [{ environmentId, label }], - sourceLabel: null, - redeem: { environmentId, input: { instanceId: provider.instanceId } }, - limits: provider.usageLimits, - }, - ); + merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, input: { instanceId: provider.instanceId } }, + limits: provider.usageLimits, + }); } } // Every hub account, including those a native instance also knows: the hub @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( - accountKey(account.driver, account.email, account.usageLimits) ?? - `${source.id}:${account.id}`, - { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }, - ); + const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; + merge(resolveKey(account) ?? key, { + key, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }); } } } @@ -401,7 +442,7 @@ export function collectLimitPools( .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; const orderReset = (account: LimitAccount) => { const window = account.limits.windows.find( - (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow), ); return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; }; @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +/** + * Codex ids are slots, not allowances: a weekly-only plan reports its weekly + * window as `primary`, other plans as `secondary`. Its windows pool by kind; + * other drivers' ids name distinct allowances. + */ +function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { + return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; +} + function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { const byKey = new Map(); for (const account of accounts) { for (const window of account.limits.windows) { - const key = `${window.kind}:${window.id}`; + const pooled = poolKey(account.driver, window); + // An account with two windows of one kind keeps the second apart. + const key = byKey.get(pooled)?.some((member) => member.account === account) + ? `${window.kind}:${window.id}` + : pooled; const list = byKey.get(key); if (list) list.push({ account, window }); else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; + // Pooled Codex slots take the pool's own key, so the id stays the same + // whichever account sorts first. + const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the // members that have a clock; a window with no reset would otherwise @@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }) .sort((left, right) => left.at - right.at); return { - id: first.id, + id, kind: first.kind, label: first.label, members, @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + const resolveKey = accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + resolveKey(account) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = resolveKey(account); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, From c3243f05088c3c20e9439f285fc6d7b0bfe1747d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:05:38 -0700 Subject: [PATCH 170/377] feat(pull-requests): hosts can report edit and resolve permissions per item (#17667) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestCodeTab.tsx | 10 ++- .../pullRequestEditing.logic.test.ts | 66 +++++++++++++++++++ .../pullRequest/pullRequestEditing.logic.ts | 30 +++++++-- packages/contracts/src/pullRequest.ts | 23 +++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1339b46196b8..d5f054dadab0 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -33,7 +33,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; -import { canEditPullRequestComment } from "./pullRequestEditing.logic"; +import { canEditPullRequestComment, canResolvePullRequestThread } from "./pullRequestEditing.logic"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -934,7 +934,7 @@ function PullRequestCodeTab({ thread={thread} workspaceRoot={detail.workspaceRoot} canReply={review.reply} - canResolve={review.resolve} + canResolve={review.resolve && canResolvePullRequestThread(detail, thread)} canReact={detail.capabilities.reactions === true} environmentId={environmentId} reference={reference} @@ -966,7 +966,11 @@ function PullRequestCodeTab({ } // A conversation on a line is made of review comments, whatever the host filed them as. canEditComment={(comment) => - canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" }) + canEditPullRequestComment(detail, { + author: comment.author, + kind: "review-comment", + canEdit: comment.canEdit, + }) } onEditComment={(commentId, body) => runThreadCommand("The comment could not be saved", () => diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts index 1b43ff209b23..06ecec5d3295 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canEditPullRequestChangeRequest, canEditPullRequestComment, + canResolvePullRequestThread, } from "./pullRequestEditing.logic"; type Subject = Pick; @@ -89,6 +90,33 @@ describe("canEditPullRequestChangeRequest", () => { ).toBe(true); }); + it("follows the host's own answer about the reader over authorship and merge access", () => { + expect( + canEditPullRequestChangeRequest( + subject({ + author: actor("someone-else"), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(true); + expect( + canEditPullRequestChangeRequest( + subject({ + viewerPermissions: permissions({ actions: ["merge"], editChangeRequest: false }), + }), + ), + ).toBe(false); + // The host can't rewrite change requests at all, so the reader's answer can't widen that. + expect( + canEditPullRequestChangeRequest( + subject({ + capabilities: capabilities({ changeRequest: false, comment: true }), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(false); + }); + it("refuses a reader who neither wrote it nor may merge it", () => { expect( canEditPullRequestChangeRequest( @@ -146,6 +174,23 @@ describe("canEditPullRequestComment", () => { ); }); + it("follows the host's own answer about a remark over authorship", () => { + expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false); + expect( + canEditPullRequestComment( + subject(), + comment({ author: actor("someone-else"), canEdit: true }), + ), + ).toBe(true); + // The host can't rewrite remarks at all, so its per-remark answer can't widen that. + expect( + canEditPullRequestComment( + subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), + comment({ canEdit: true }), + ), + ).toBe(false); + }); + it("refuses a remark the host attributes to nobody", () => { expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); }); @@ -170,3 +215,24 @@ describe("canEditPullRequestComment", () => { expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); }); }); + +describe("canResolvePullRequestThread", () => { + it("uses the reader's repository-wide permission where the host says nothing per thread", () => { + expect(canResolvePullRequestThread(subject(), {})).toBe(true); + expect( + canResolvePullRequestThread( + subject({ viewerPermissions: permissions({ resolve: false }) }), + {}, + ), + ).toBe(false); + }); + + it("only narrows the reader's permission with the host's answer for one thread", () => { + expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false); + expect( + canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), { + canResolve: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts index a7e49f3f635f..520ff7a7ef96 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts @@ -1,4 +1,8 @@ -import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts"; +import type { + PullRequestComment, + PullRequestDetail, + PullRequestReviewThread, +} from "@t3tools/contracts"; /** Only the parts of a detail either answer reads, so a caller can pass a whole detail view. */ type EditingSubject = Pick< @@ -14,12 +18,15 @@ function sameLogin(one: string | null | undefined, other: string | null | undefi /** * Whether the title and description can be rewritten from here. Beyond the host being able to at - * all, either the reader wrote the change request or they may merge it — merging is the one action - * every host here grants with write access and withholds without it, so it stands in for the - * permission none of them publishes by name. + * all, a host that reports `editChangeRequest` answers directly. Otherwise either the reader wrote + * the change request or they may merge it: merging is the one action every host here grants with + * write access and withholds without it, so it stands in for the permission they don't publish. */ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean { if (detail.capabilities.edit?.changeRequest !== true) return false; + if (detail.viewerPermissions.editChangeRequest !== undefined) { + return detail.viewerPermissions.editChangeRequest; + } return ( sameLogin(detail.viewer, detail.author?.login) || detail.viewerPermissions.actions.includes("merge") @@ -33,9 +40,22 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean */ export function canEditPullRequestComment( detail: EditingSubject, - comment: Pick, + comment: Pick, ): boolean { if (detail.capabilities.edit?.comment !== true) return false; if (comment.kind !== "issue-comment" && comment.kind !== "review-comment") return false; + if (comment.canEdit !== undefined) return comment.canEdit; return sameLogin(detail.viewer, comment.author?.login); } + +/** + * Whether the reader may resolve or reopen one review thread. The host's answer for this thread + * can only narrow the reader's repository-wide `resolve` permission: a thread can't grant what the + * repository withholds. + */ +export function canResolvePullRequestThread( + detail: Pick, + thread: Pick, +): boolean { + return detail.viewerPermissions.resolve && thread.canResolve !== false; +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2cd0e45cc8e0..1efbc1808d14 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -209,6 +209,12 @@ export const PullRequestComment = Schema.Struct({ reviewState: Schema.NullOr(Schema.String), /** Absent from a host with no reactions at all, which is a different thing from none on this. */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestComment = typeof PullRequestComment.Type; @@ -232,6 +238,12 @@ export const PullRequestThreadComment = Schema.Struct({ editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; @@ -257,6 +269,12 @@ export const PullRequestReviewThread = Schema.Struct({ commentCount: Schema.optional(NonNegativeInt), /** Opaque cursor for the next comment page. Absent once this thread is whole. */ nextCommentsCursor: Schema.optional(TrimmedNonEmptyString), + /** + * The host's own answer to whether this reader may resolve this thread. It can only narrow the + * reader's repository-wide `resolve` permission, never widen it. Absent where the host + * doesn't say per thread. + */ + canResolve: Schema.optional(Schema.Boolean), }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; @@ -481,6 +499,11 @@ export const PullRequestViewerPermissions = Schema.Struct({ * changed on this host at all. */ labels: Schema.optional(Schema.Boolean), + /** + * This viewer may rewrite the change request's title and description. Absent where the host + * doesn't say, which leaves the page to guess from authorship and merge access. + */ + editChangeRequest: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; From 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:22 -0400 Subject: [PATCH 171/377] perf(server): run Git for Windows' real git.exe, not its launcher (#17707) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++- packages/shared/src/shell.test.ts | 89 +++++++++++++++++++++++++ packages/shared/src/shell.ts | 48 ++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 51c1f7b29468..53e7f6418058 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...input.env, ...trace2Monitor.env, }; + const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; + const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); + // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit + // messages at the first newline; leave that case to Node's lookup. + const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( - ChildProcess.make("git", commandInput.args, { + ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, - env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, + env: spawnEnv, }), ) .pipe( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 428d0d5951f5..5f493c8044d3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -15,6 +15,7 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + preferGitForWindowsBinary, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +describe("preferGitForWindowsBinary", () => { + const files = + (...paths: Array) => + (filePath: string) => + paths.includes(filePath); + + it("runs the git.exe Git for Windows' launcher would start", () => { + // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.EXE", + {}, + files( + "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe", + "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", + ), + ), + ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); + // Before 2.56, and the portable build's bin launcher. + expect( + preferGitForWindowsBinary( + "D:\\PortableGit\\bin\\git.exe", + {}, + files("D:\\PortableGit\\mingw64\\bin\\git.exe"), + ), + ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe"); + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + {}, + files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"); + }); + + it("keeps the launcher when MSYSTEM is set", () => { + // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; + // without them a `#!/bin/sh` hook cannot start. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + { MSYSTEM: "MINGW64" }, + files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); + }); + + it("keeps the launcher when no git.exe sits beside it", () => { + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe( + "C:\\Program Files\\Git\\cmd\\git.exe", + ); + }); + + it("leaves other gits and other launchers alone", () => { + const everything = () => true; + expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe( + "C:\\Users\\me\\scoop\\shims\\git.exe", + ); + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe( + "C:\\Program Files\\Git\\cmd\\gitk.exe", + ); + }); +}); + effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { it.effect("runs Windows executables directly without a shell", () => Effect.gen(function* () { @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); + it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () => + Effect.gen(function* () { + let scans = 0; + const scan: SpawnExecutableResolver = () => { + scans++; + return "C:\\Git\\cmd\\git.exe"; + }; + const resolve = (env: NodeJS.ProcessEnv) => + resolveSpawnCommand("git", [], { + env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env }, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, scan), + ); + + yield* resolve({}); + yield* resolve({}); + expect(scans).toBe(1); + // A swap made without MSYSTEM must not reach a child that has it set. + yield* resolve({ MSYSTEM: "MINGW64" }); + expect(scans).toBe(2); + }).pipe(Effect.provideService(CommandResolutionCache, new Map())), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index b24c998b1785..4d29841bfa8b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds +// live in clangarm64 and 32-bit ones in mingw32. +const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; + +function isFileSync(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Swaps Git for Windows' launcher (`\cmd\git.exe`, the only git its + * installer puts on PATH, or the portable build's `\bin\git.exe`) for the + * git.exe it starts. The launcher costs a second process on every git command, + * and each launch leaks a kernel token reference that slows process creation + * machine-wide until reboot. The real binary sets HOME itself, but adds its own + * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is + * unset, so the launcher stays when MSYSTEM is set. Anything else is returned + * as is. + */ +export function preferGitForWindowsBinary( + executable: string, + env: NodeJS.ProcessEnv, + isFile: (filePath: string) => boolean = isFileSync, +): string { + if (env.MSYSTEM) return executable; + const path = NodePath.win32; + if (path.basename(executable).toLowerCase() !== "git.exe") return executable; + const launcherDirectory = path.dirname(executable); + const launcherFolder = path.basename(launcherDirectory).toLowerCase(); + if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; + const installRoot = path.dirname(launcherDirectory); + for (const build of GIT_FOR_WINDOWS_BUILDS) { + const candidate = path.join(installRoot, build, "bin", "git.exe"); + if (isFile(candidate)) return candidate; + } + return executable; +} + // Untraced because it runs before most spawns and returns at once off Windows. export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - resolvedExecutable = resolveExecutable(command, platform, env) ?? null; + // Cached with the scan: its file checks would otherwise run before every + // git launch. A Git upgrade that moves the real binary can fail git for up + // to the cache lifetime. + const found = resolveExecutable(command, platform, env); + resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } From a7223f3969428170cc13bd7859aa59098b2f94b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:09:20 -0700 Subject: [PATCH 172/377] fix(clients): restart continuations show as a T3 Code notice, not another agent's message (#17723) Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 8 +++- apps/mobile/src/lib/threadActivity.test.ts | 20 +++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++- .../src/orchestration-v2/Orchestrator.ts | 8 +++- .../RestartContinuation.test.ts | 26 ++++++++-- .../orchestration-v2/RestartContinuation.ts | 22 ++++++--- .../src/orchestration-v2/runtimeLayer.test.ts | 17 +++++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- .../src/state/threadExecution.ts | 1 + .../client-runtime/src/userMessage.test.ts | 31 ++++++++++-- packages/client-runtime/src/userMessage.ts | 48 +++++++++++++++---- packages/contracts/src/orchestrationV2.ts | 2 + 12 files changed, 171 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a210ffcb1dc6..ee0c4711b03b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1719,11 +1719,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation ? ( + {presentation.attribution === "automation" ? ( Sent by automation - ) : message.createdBy === "agent" ? ( + ) : presentation.attribution === "t3code" ? ( + + Sent by T3 Code + + ) : presentation.attribution === "agent" ? ( { expect(withoutPath?.canExpand).toBe(false); }); +it("expands a T3 Code notice to the prompt the agent got", () => { + const item = { + ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), + type: "notification", + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + } as OrchestrationV2TurnItem; + const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getFullDetail()).toBe("Continue where you left off."); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { expect(messageEntry).toBeDefined(); expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ text: "Run checks", - isAutomation: true, + attribution: "automation", }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11..edaaf2b0fab8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "monitor": return "eye"; case "background_task": + case "system": return "zap"; default: source satisfies never; @@ -814,7 +815,11 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 475c6bea0a94..3f17a6faa55d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } } + // A restart continuation resumes the interrupted turn, so it starts + // immediately rather than queueing like other wakes. if ( command.notification !== undefined && (command.createdBy !== "agent" || (command.creationSource !== "server" && command.creationSource !== "provider") || - dispatchMode.type !== "queue_after_active") + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa63728..34b3d8352c68 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -10,6 +10,7 @@ import { RunAttemptId, RunId, ThreadId, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => Effect.gen(function* () { - const texts: Array = []; + const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( Effect.provide( Layer.merge( @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => getThreadRecords: () => Effect.succeed(projection), recoverDelegatedTask: () => Effect.void, dispatch: (command) => { - if (command.type === "message.dispatch") texts.push(command.text); + if (command.type === "message.dispatch") dispatches.push(command); return Effect.succeed({} as never); }, }), @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => ), ), ); - return texts; + return dispatches; }); +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + continuationDispatches(projection).pipe( + Effect.map((dispatches) => dispatches.map((command) => command.text)), + ); + const cutMidTurn = (extra: Record = {}) => { const base = makeProjection(); return { @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => }), ); +it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => + Effect.gen(function* () { + const [resumed] = yield* continuationDispatches(cutMidTurn()); + assert.deepEqual(resumed?.notification, { + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }); + }), +); + it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc4..b7e905d0f4a9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const text = + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`; yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb70c880b061..3d4bf4c2a64a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("restart-automatic-message"), text: "Continue where you left off.", + notification: { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" as const }, @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const admitted = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf(admitted.runs, 2); assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); + // The timeline shows a work log row; the prompt stays in its detail. + const continuationItems = admitted.turnItems.filter( + (item) => item.runId === admitted.runs[1]?.id, + ); + assert.isFalse(continuationItems.some((item) => item.type === "user_message")); + assert.deepInclude( + continuationItems.flatMap((item) => + item.type === "notification" ? [[item.summary, item.detail]] : [], + ), + ["T3 Code restarted and resumed this turn", "Continue where you left off."], + ); // A differently identified stale delivery still must not create another run. yield* orchestrator.dispatch({ ...command, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a3138c29044b..0518c96afc58 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.isAutomation ? ( + {userMessage.attribution === "automation" ? (

- ) : row.message.createdBy === "agent" ? ( + ) : userMessage.attribution === "t3code" ? ( +

+ Sent by T3 Code +

+ ) : userMessage.attribution === "agent" ? (

{senderThreadId ? ( { it("removes attribution from legacy scheduled prompts", () => { expect( resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), - ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); + ).toEqual({ + text: "Check for crashes.\n", + attribution: "automation", + scheduledTaskId: undefined, + }); }); it("uses task metadata without changing the prompt", () => { @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { scheduledTaskId: ScheduledTaskId.make("task-1"), text: legacyText, }), - ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); + ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); }); it("preserves user-written and assistant-quoted schedule headers", () => { @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ text: legacyText, - isAutomation: false, + attribution: null, scheduledTaskId: undefined, }); } @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ text, - isAutomation: false, + attribution: "agent", scheduledTaskId: undefined, }); } }); + it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { + const text = "Note: the T3 server restarted."; + expect( + resolveUserMessagePresentation({ + role: "user", + createdBy: "agent", + creationSource: "server", + text, + }), + ).toMatchObject({ attribution: "t3code" }); + for (const creationSource of ["mcp", "provider"] as const) { + expect( + resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), + ).toMatchObject({ attribution: "agent" }); + } + }); + it("recovers the triggering automation from older scheduler message ids", () => { for (const trigger of ["scheduled", "manual"]) { expect( @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { }), ).toEqual({ text: "Check for crashes.\n", - isAutomation: true, + attribution: "automation", scheduledTaskId: "task:daily-audit", }); } diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 5acad0a44802..b292bafe0ca4 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,31 +1,59 @@ -import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; +import { + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, + ScheduledTaskId, +} from "@t3tools/contracts"; const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** Older scheduled messages stored their attribution in the prompt itself. */ +/** + * Text and sender label for a user-role message. Older scheduled messages + * stored their attribution in the prompt itself. + */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; readonly text: string; readonly createdBy?: OrchestrationV2Actor; + readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; -}) { +}): { + readonly text: string; + /** Who sent a user-role message the user did not type. */ + readonly attribution: "automation" | "agent" | "t3code" | null; + readonly scheduledTaskId: ScheduledTaskId | undefined; +} { if (message.role !== "user") { - return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; + return { text: message.text, attribution: null, scheduledTaskId: undefined }; } if (message.scheduledTaskId !== undefined) { - return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; + return { + text: message.text, + attribution: "automation", + scheduledTaskId: message.scheduledTaskId, + }; } const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); const legacyTaskId = legacyPrefix ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] : undefined; - const isAutomation = - legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); + if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { + return { + text: message.text.slice(legacyPrefix[0].length), + attribution: "automation", + scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + }; + } return { - text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, - isAutomation, - scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + text: message.text, + // Restart continuations were sent as the agent before they became notices. + attribution: + message.createdBy !== "agent" + ? null + : message.creationSource === "server" + ? "t3code" + : "agent", + scheduledTaskId: undefined, }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 423f8234178b..7aa8c74fd80a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), + /** T3 Code itself, such as a restart continuing an interrupted turn. */ + Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), () => ({ kind: "background_task" }), From 57b3780770a829ff81e62d8dc658683bf51d5155 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:13:32 -0700 Subject: [PATCH 173/377] fix(mobile): browser picture in picture opens from the header button (#17731) Co-authored-by: Claude Opus 5.5 --- .../browser/preview-stream.browser.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 07a9953cc5a2..91fa5d7a7085 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = interface Viewer { readonly stop: () => void; readonly command: (input: PreviewStreamInput) => void; - readonly togglePictureInPicture: () => Promise; + readonly togglePictureInPicture: () => void; } let activeViewer: Viewer | null = null; @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { } export function pictureInPicture() { - void activeViewer?.togglePictureInPicture(); + activeViewer?.togglePictureInPicture(); } export function start(configuration: PreviewStreamConfiguration) { @@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) { }; const painter = createPreviewFramePainter(canvas, () => { if (!streaming && !stopped) reportStatus("streaming"); + sendPictureInPictureFrame(); }); const send = (message: PreviewStreamInput) => client?.send(message) ?? false; const clearInput = () => { @@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) { const pictureInPictureSupported = interactive && typeof canvas.captureStream === "function" && + typeof CanvasCaptureMediaStreamTrack === "function" && ((document.pictureInPictureEnabled === true && typeof videoPrototype.requestPictureInPicture === "function") || typeof videoPrototype.webkitSetPresentationMode === "function"); + // Since the iOS 17 SDK, WebKit revokes the activation it grants an injected script when + // the script returns, so the native button's request must start before any await. The + // video is ready ahead of the tap: paused, and fed a frame only when it has none or + // floats, so the inline view captures nothing. let video: PresentationVideo | null = null; + let track: CanvasCaptureMediaStreamTrack | null = null; const pictureInPictureActive = () => video !== null && (document.pictureInPictureElement === video || @@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) { active: pictureInPictureActive(), ...(detail ? { detail } : {}), }); - const ensureVideo = () => { - if (video) return video; + // Called after each paint. A frame drawn before the player attached is lost, so retry. + const sendPictureInPictureFrame = () => { + if (video && (video.readyState === HTMLMediaElement.HAVE_NOTHING || pictureInPictureActive())) { + track?.requestFrame(); + } + }; + if (pictureInPictureSupported) { + const stream = canvas.captureStream(0); + const [captured] = stream.getVideoTracks(); const element: PresentationVideo = document.createElement("video"); element.muted = true; element.playsInline = true; - element.autoplay = true; // Under the canvas at full size: WebKit pauses muted video it considers off screen. - element.srcObject = canvas.captureStream(); - // A static page sends no new frames; repaint once so the stream has one. - if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); + element.srcObject = stream; for (const name of [ "enterpictureinpicture", "leavepictureinpicture", @@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) { }); } container.prepend(element); + // Covers the letterbox, where a stale frame of another shape would show through. + canvas.style.background = configuration.background; video = element; - return element; - }; - const togglePictureInPicture = async () => { - if (!pictureInPictureSupported) return; - try { - if (pictureInPictureActive()) { - if (document.pictureInPictureElement) await document.exitPictureInPicture(); - else video?.webkitSetPresentationMode?.("inline"); - return; - } - const element = ensureVideo(); - await element.play(); - if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); - else element.webkitSetPresentationMode?.("picture-in-picture"); - } catch (error) { + track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null; + } + const togglePictureInPicture = () => { + const element = video; + if (!element) return; + if (pictureInPictureActive()) { + if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {}); + else element.webkitSetPresentationMode?.("inline"); + return; + } + if (element.readyState === HTMLMediaElement.HAVE_NOTHING) { + reportPictureInPicture("The page has not shown yet. Try again in a moment."); + return; + } + // The page may have changed since the video's last frame. WebKit captures only on a draw. + track?.requestFrame(); + canvas.getContext("2d")?.drawImage(canvas, 0, 0); + const fail = (error: unknown) => { + element.pause(); reportPictureInPicture( error instanceof Error ? error.message : "Picture in picture is unavailable.", ); + }; + if (document.pictureInPictureEnabled) { + const entering = element.requestPictureInPicture(); + void Promise.all([entering, element.play()]).catch(fail); + } else { + element.webkitSetPresentationMode?.("picture-in-picture"); + void element.play().catch(fail); } }; @@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) { painter.stop(); client?.stop(); client = null; - if (video) { - for (const track of video.srcObject instanceof MediaStream - ? video.srcObject.getTracks() - : []) { - track.stop(); - } - video.srcObject = null; - } + track?.stop(); + if (video) video.srcObject = null; }, command: (message) => { send(message); From 65d33177b777e014c56f01851bef446785b3aa28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:18:22 -0700 Subject: [PATCH 174/377] feat(source-control): GitCafe lives in @t3tools/source-control-gitcafe (#17681) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 1 + .../SourceControlDiscovery.test.ts | 21 + .../SourceControlProviderRegistry.test.ts | 2 + .../src/sourceControl/builtInDrivers.ts | 5 + apps/web/src/components/CommandPalette.tsx | 20 +- apps/web/src/components/GitActionsControl.tsx | 12 +- apps/web/src/components/Icons.tsx | 8 + .../ThreadStatusIndicators.test.tsx | 22 + .../src/components/ThreadStatusIndicators.tsx | 23 +- .../pullRequest/pullRequestDetail.logic.ts | 2 + .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 2 + apps/web/src/sourceControlPresentation.ts | 7 + apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 20 +- package.json | 2 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/changeRequestUrl.ts | 7 + packages/shared/src/sourceControl.ts | 31 +- packages/source-control-gitcafe/package.json | 52 ++ .../src/server/GitCafeApi.ts | 176 ++++ .../src/server/GitCafeCredentials.test.ts | 155 ++++ .../src/server/GitCafeCredentials.ts | 166 ++++ .../server/GitCafePullRequestProvider.test.ts | 751 +++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 867 ++++++++++++++++++ .../GitCafeSourceControlProvider.test.ts | 339 +++++++ .../server/GitCafeSourceControlProvider.ts | 503 ++++++++++ .../src/server/driver.ts | 20 + .../src/server/gitCafeHosts.test.ts | 46 + .../src/server/gitCafeHosts.ts | 49 + .../src/server/gitCafePullRequestJson.test.ts | 547 +++++++++++ .../src/server/gitCafePullRequestJson.ts | 578 ++++++++++++ packages/source-control-gitcafe/tsconfig.json | 7 + pnpm-lock.yaml | 34 + scripts/release-smoke.ts | 1 + 35 files changed, 4463 insertions(+), 17 deletions(-) create mode 100644 packages/source-control-gitcafe/package.json create mode 100644 packages/source-control-gitcafe/src/server/GitCafeApi.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/driver.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts create mode 100644 packages/source-control-gitcafe/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e..1147b5e06233 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535f..8fa9201b94ca 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unknown", account: Option.none(), }, + { + kind: "gitcafe", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } + if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { + return Effect.succeed( + processOutput( + encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), + ), + ); + } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("forgejo-user"), detail: Option.none(), }, + { + kind: "gitcafe", + auth: "authenticated", + account: Option.some("cafe-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(layerTest)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bfed745ca295..1fab84a6acd0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -126,6 +127,7 @@ function makeRegistry(input: { Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 2e830dec803e..1b891ce9259a 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; @@ -32,6 +35,7 @@ const drivers = [ AzureDevOpsDriver.driver, BitbucketDriver.driver, ForgejoDriver.driver, + GitCafeDriver.driver, ]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ @@ -51,5 +55,6 @@ export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, + GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df..48c4b20a777f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -187,7 +187,14 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitCafeIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "forgejo", "bitbucket", "azure-devops", + "gitcafe", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "azure-devops": return ; + case "gitcafe": + return ; case "url": return ; } @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, + gitcafe: unavailable, }; if (!discovery) { @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitcafe", "url", "environment", ], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cfe45ff24cc4..2b684cd0cd4e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,7 @@ import { GitHubIcon, GitLabIcon, ForgejoIcon, + GitCafeIcon, } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type GitActionToastId = ReturnType; @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ pathPlaceholder: "project/repository", Icon: AzureDevOpsIcon, }, + { + value: "gitcafe", + label: "GitCafe", + description: "git.cafe", + host: "git.cafe", + pathPlaceholder: "owner/repo", + Icon: GitCafeIcon, + }, ] as const satisfies ReadonlyArray<{ readonly value: PublishProviderKind; readonly label: string; @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { forgejo: null, bitbucket: null, "azure-devops": null, + gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63..909b9c7456f3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( ); +// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not +// read heavier than the round and cut-out marks beside it. +export const GitCafeIcon: Icon = (props) => ( + +); + // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup // step points at the same icon the user is about to look for. export const MacScreenRecordingIcon: Icon = (props) => { diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448e..3ae07b403063 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, }); }); + it.each([ + ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], + ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], + ["https://github.com/owner/repo/pull/42", "github"], + ])("classifies %s as %s", (url, kind) => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + url, + snapshot: { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result?.sourceControlProvider.kind).toBe(kind); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a292..8368dbb0eac9 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,6 +6,7 @@ import { import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveEnvironmentMachineKind, type EnvironmentId, @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - const kind = link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. + const kind = + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" + ? "gitcafe" + : link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : link.url.includes("/pulls/") + ? "forgejo" + : "github"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 00d3aafd2d2f..083f2c8db22a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( : null; case "azure-devops": return `az repos pr checkout --id ${number}`; + case "gitcafe": + return `cafe pr checkout ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559..6b673ad98325 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { gitlab: "Open on GitLab", forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", + gitcafe: "Open on GitCafe", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d..68465b06d0fd 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, + GitCafeIcon, GitHubIcon, GitIcon, GitLabIcon, @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 7a8d2cab8fe5..61a1befd6099 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -16,6 +16,7 @@ import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, + GitCafeIcon, GitHubIcon, GitLabIcon, } from "./components/Icons"; @@ -62,6 +63,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: BitbucketIcon, }; + case "gitcafe": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitCafeIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 23b44f7992bb..f760678136dc 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3730eb6a18d4..b0452974f1c1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish -repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone +and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -96,6 +96,22 @@ az extension add --name azure-devops az login ``` +### GitCafe + +Install the GitCafe CLI and sign in: + +```bash +bun install -g @gitcafe/cli +cafe auth login --host https://git.cafe/api +``` + +Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only +sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +`staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. + +Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. +Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index ba161940d2f7..e21d895fd656 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 06934e93c740..c4d48e3ccd03 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitcafe", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166..265c31c157fb 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -96,6 +101,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "gitcafe": + return `https://${host}/${repository}/pulls/${number}`; case "forgejo": { try { const remote = new URL(remoteUrl ?? ""); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index ddfa3bdf9b46..be09c017767e 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitcafe" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; +const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitcafe", + providerName: "GitCafe", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitCafe pull request", + checkoutCommandExample: "cafe pr checkout 123", + urlExample: "https://git.cafe/owner/repo/pulls/42", +}; + const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "gitcafe": + return GITCAFE_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { ); } +/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ +function isGitCafeHost(host: string): boolean { + return host === "git.cafe" || host === "staging.git.cafe"; +} + function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if (isGitCafeHost(hostname)) { + return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + } + if ( hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo") || diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json new file mode 100644 index 000000000000..eeda68c5e92d --- /dev/null +++ b/packages/source-control-gitcafe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@t3tools/source-control-gitcafe", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitCafeHosts": { + "types": "./src/server/gitCafeHosts.ts", + "import": "./src/server/gitCafeHosts.ts" + }, + "./server/GitCafeApi": { + "types": "./src/server/GitCafeApi.ts", + "import": "./src/server/GitCafeApi.ts" + }, + "./server/GitCafeCredentials": { + "types": "./src/server/GitCafeCredentials.ts", + "import": "./src/server/GitCafeCredentials.ts" + }, + "./server/gitCafePullRequestJson": { + "types": "./src/server/gitCafePullRequestJson.ts", + "import": "./src/server/gitCafePullRequestJson.ts" + }, + "./server/GitCafePullRequestProvider": { + "types": "./src/server/GitCafePullRequestProvider.ts", + "import": "./src/server/GitCafePullRequestProvider.ts" + }, + "./server/GitCafeSourceControlProvider": { + "types": "./src/server/GitCafeSourceControlProvider.ts", + "import": "./src/server/GitCafeSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts new file mode 100644 index 000000000000..949ece637faa --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -0,0 +1,176 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +const NO_REDIRECT: RequestInit = { redirect: "manual" }; +const TIMEOUT = Duration.seconds(30); + +/** + * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token + * disables the provider, a limit pauses the host, and the rest belong to the one request. + */ +export const GitCafeApiFailure = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); + +export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { + host: Schema.String, + operation: Schema.String, + /** GitCafe's HTTP status; null when no answer arrived. */ + status: Schema.NullOr(Schema.Int), + reason: GitCafeApiFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`; + } +} + +export interface GitCafeApiRequest { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ + readonly path: string; + readonly body?: unknown; +} + +/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ +export class GitCafeApi extends Context.Service< + GitCafeApi, + { + /** The body of a successful answer. */ + readonly request: (input: GitCafeApiRequest) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} + +/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ +const decodeProblem = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + title: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + }), + ), +); + +const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type => + status === 401 + ? "unauthenticated" + : status === 429 + ? "rate-limited" + : status === 404 + ? "not-found" + : "failed"; + +const MAX_PROBLEM_TEXT = 300; +const boundedProblemText = (text: string | undefined) => { + const line = text?.replace(/\s+/gu, " ").trim(); + if (!line) return undefined; + return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}…` : line; +}; + +const credentialFailure = ( + error: GitCafeCredentials.GitCafeCredentialUnavailableError, +): typeof GitCafeApiFailure.Type => + error._tag === "GitCafeCliMissingError" + ? "missing-tool" + : error._tag === "GitCafeNotSignedInError" + ? "unauthenticated" + : "failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + + const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) { + const fail = ( + fields: Pick & { cause?: unknown }, + ) => + new GitCafeApiError({ + host: input.host, + operation: input.operation, + status: fields.status, + reason: fields.reason, + detail: fields.detail, + ...(fields.cause === undefined ? {} : { cause: fields.cause }), + }); + // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + const host = GitCafeHosts.gitCafeHost(input.host); + if (host === null) + return yield* fail({ + status: null, + reason: "failed", + detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`, + }); + yield* Effect.annotateCurrentSpan({ + "gitcafe.host": host, + "gitcafe.operation": input.operation, + "http.request.method": input.method ?? "GET", + }); + const credential = yield* credentials + .get(host) + .pipe( + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const base = HttpClientRequest.make(input.method ?? "GET")( + `https://${host}/api${input.path}`, + ).pipe( + HttpClientRequest.bearerToken(Redacted.value(credential.token)), + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + ); + const { status, text } = yield* Effect.gen(function* () { + const response = yield* httpClient + .execute( + input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ) + .pipe( + // The client's own span would record the query string, which can carry branch names. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; + // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + ); + return { status: response.status, text: yield* response.text }; + }).pipe( + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }), + ), + ); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); + if (status >= 200 && status < 300) return text; + // The source may hold a newer token than the one that was refused. + if (status === 401) yield* credentials.invalidate(host); + const problem = Option.getOrUndefined(decodeProblem(text)); + return yield* fail({ + status, + reason: reasonForStatus(status), + // GitCafe's own explanation is what the user acts on; it is kept short and single-line. + detail: + boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, + }); + }); + + return GitCafeApi.of({ request }); +}); + +export const layer = Layer.effect(GitCafeApi, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts new file mode 100644 index 000000000000..f0f8be74711f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -0,0 +1,155 @@ +/** + * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: + * + * - `class GitCafeCredentials extends Context.Service` keyed + * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with + * `get(host) => Effect` and + * `invalidate(host) => Effect`. + * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. + * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, + * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). + * - `environmentToken(host, env): string | null` — `CAFE_TOKEN` only for the host `CAFE_HOST` + * names (git.cafe when unset). + * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and + * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +function harness( + run: SourceControlHost.SourceControlHost["Service"]["process"]["run"], + env: NodeJS.ProcessEnv = {}, +) { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, env)), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return { layer, calls }; +} + +describe("GitCafeCredentials", () => { + it("hands CAFE_TOKEN only to the host cafe itself targets", () => { + const token = { CAFE_TOKEN: "env-token" }; + assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token"); + assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token)); + for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) { + const staging = { ...token, CAFE_HOST }; + assert.strictEqual( + GitCafeCredentials.environmentToken("staging.git.cafe", staging), + "env-token", + ); + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging)); + } + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " })); + }); + + it.effect("prefers CAFE_TOKEN without running cafe", () => { + const { layer, calls } = harness(() => Effect.die("cafe must not run"), { + CAFE_TOKEN: "env-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const credential = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(credential.token), "env-token"); + assert.strictEqual(credential.source, "env"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { + const { layer, calls } = harness(() => + Effect.succeed( + processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"), + ), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + // A token for staging is never taken from an environment that targets production. + const credential = yield* credentials.get("Staging.Git.Cafe"); + assert.strictEqual(Redacted.value(credential.token), "gct_1"); + assert.strictEqual(credential.source, "cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.command, "cafe"); + assert.deepStrictEqual(calls[0]?.args, [ + "auth", + "http", + "credential", + "--host", + "https://staging.git.cafe/api", + "get", + ]); + assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); + // A refused token is asked for again rather than reused. + yield* credentials.invalidate("staging.git.cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 2); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + [ + "a missing CLI", + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + "GitCafeCliMissingError", + ], + [ + "a helper with no login", + Effect.fail( + new VcsProcessExitError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + exitCode: 1, + detail: "not signed in", + }), + ), + "GitCafeNotSignedInError", + ], + [ + "an answer without a password", + Effect.succeed(processOutput("username=alice\n")), + "GitCafeNotSignedInError", + ], + ] as const)("reports %s", ([, result, tag]) => { + const { layer } = harness(() => result); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const error = yield* credentials.get("git.cafe").pipe(Effect.flip); + assert.strictEqual(error._tag, tag); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts new file mode 100644 index 000000000000..c47e9ce1153f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -0,0 +1,166 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; + +/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export interface GitCafeCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: "env" | "cafe"; +} + +/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ +export class GitCafeCliMissingError extends Schema.TaggedError()( + "GitCafeCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`; + } +} + +/** `cafe` is installed but holds no login for the host. */ +export class GitCafeNotSignedInError extends Schema.TaggedError()( + "GitCafeNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`; + } +} + +/** `cafe` timed out or failed for a reason other than having no login. */ +export class GitCafeCliFailedError extends Schema.TaggedError()( + "GitCafeCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`; + } +} + +export type GitCafeCredentialUnavailableError = + | GitCafeCliMissingError + | GitCafeNotSignedInError + | GitCafeCliFailedError; + +/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ +export class GitCafeCredentials extends Context.Service< + GitCafeCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} + +const normalizeHost = (host: string) => host.trim().toLowerCase(); + +/** + * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by + * default): a remote URL picks the host, and must not steer a production token to staging. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const configured = env.CAFE_HOST?.trim(); + const configuredHost = configured + ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host + : "git.cafe"; + if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; + return env.CAFE_TOKEN?.trim() || null; +} + +/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ +function credentialPassword(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; + + /** + * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand + * the stored login to another program, and answers over stdin/stdout so the token never + * reaches an argument list or a log. + */ + const fromCafe = (host: string) => + process + .run({ + operation: "GitCafeCredentials.get", + command: "cafe", + args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"], + cwd: workingDirectory, + stdin: `protocol=https\nhost=${host}\n\n`, + env: { CAFE_NO_UPDATE_CHECK: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitCafeCliMissingError({ host }) + : error._tag === "VcsProcessExitError" + ? new GitCafeNotSignedInError({ host }) + : new GitCafeCliFailedError({ host, cause: error }), + ), + Effect.map((output) => credentialPassword(output.stdout)), + Effect.filterOrFail( + (token): token is string => token !== null, + () => new GitCafeNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromCafe(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "cafe", + } satisfies GitCafeCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 4, + // A transient failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitCafeCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitCafeCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitCafeCredentials, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts new file mode 100644 index 000000000000..0603dfec80ec --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -0,0 +1,751 @@ +/** + * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer + * supports. Pass 2 creates: + * + * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) + * with `request({ host, operation, method?, path, body? }) => Effect` + * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer + * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. + * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. + * - `./GitCafePullRequestProvider.ts`: `make: Effect` + * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, + * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by + * `expectedStackHeads`. + * + * Every write carries a fresh `requestId`, which GitCafe requires. + * + * Out of scope here (layer 3): review-revision fencing, async outcome polling. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +interface SentRequest { + readonly method: string; + /** The path below `/api`, without its query. */ + readonly path: string; + readonly query: URLSearchParams; + readonly host: string; + readonly body: unknown; + /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ + readonly redirect: string | undefined; +} + +/** + * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 + * problem document. Every request is recorded in order. + */ +function fakeGitCafe( + routes: Record unknown)>, + options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, +) { + const sent: Array = []; + const client = HttpClient.make( + (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => { + const url = new URL(request.url); + assert.isTrue(url.pathname.startsWith("/api/"), url.pathname); + assert.strictEqual(request.headers.authorization, "Bearer env-token"); + const entry: SentRequest = { + method: request.method, + path: url.pathname.slice("/api".length), + query: url.searchParams, + host: url.host, + body: + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined, + redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect, + }; + sent.push(entry); + const route = routes[`${entry.method} ${entry.path}`]; + const response = + route === undefined + ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 }) + : new Response(encodeJson(typeof route === "function" ? route(entry) : route), { + status: options.status ?? 200, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }, + ); + const layer = GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provide(TestSourceControlHost.layer()), + // An environment token, so no test runs `cafe`. + Layer.provide( + Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + const writes = () => + sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`); + return { layer, sent, writes }; +} + +const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; +const timestamp = "2026-09-12T12:00:00Z"; +const headOid = "0123456789abcdef0123456789abcdef01234567"; +const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid, + observedBaseOid: baseOid, + mergeRoute: "provider", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 4, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const root = { + id: "root", + threadId: "root", + version: 2, + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: headOid, + resolvedAt: null, + capabilities: { + edit: true, + hide: false, + unhide: false, + delete: false, + resolve: true, + unresolve: false, + }, +}; +/** + * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged + * layer's head comes from its own pull. + */ +const stack = ( + members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, +): Record => ({ + "GET /repos/owner/repo/pulls/7/stack": { + stack: { + id: "stack-one", + number: 3, + revision: 5, + landingBase: "main", + members: members.map((member, index) => ({ + pullRequestNumber: member.number, + title: `Layer ${member.number}`, + state: member.state ?? "open", + draft: false, + sourceBranch: `layer-${member.number}`, + position: index + 1, + })), + }, + }, + ...Object.fromEntries( + members.map((member) => [ + `GET /repos/owner/repo/pulls/${member.number}`, + { ...pull, number: member.number, headOid: member.headOid }, + ]), + ), +}); + +/** GitCafe refuses a write without an idempotency key. */ +const assertRequestId = (body: unknown) => + assert.match( + (body as { readonly requestId?: unknown } | undefined)?.requestId as string, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + +describe("GitCafePullRequestProvider", () => { + it.effect("lists a repository's pull requests from its pulls collection", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "all", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual( + page.items.map((item) => item.number), + [7], + ); + assert.deepStrictEqual( + server.sent.map((request) => `${request.method} ${request.path}`), + ["GET /repos/owner/repo/pulls"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps a staging repository's requests and links on staging", () => { + // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7": pull }, + { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const summary = yield* provider.getChangeRequestSummary!({ + ...target, + host: "staging.git.cafe", + }); + assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe")); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .getChangeRequest({ ...target, host: "github.com" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [401, "unauthenticated"], + [429, "rate-limited"], + ] as const) { + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7/stack": { stack: null } }, + { + status, + }, + ); + const error = yield* GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.flip, + Effect.provide(server.layer), + ); + assert.strictEqual(error.reason, reason); + } + }), + ); + + it.effect("never lets fetch follow a redirect with the token", () => { + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } }); + return GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + server.sent.map((r) => r.redirect), + ["manual"], + ), + ), + ), + Effect.provide(server.layer), + ); + }); + + it.effect("reads the stack a pull request belongs to, with each open layer's head", () => { + const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([ + { number: 6, headOid: baseOid, state: "merged" }, + { number: 7, headOid }, + ]); + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getChangeRequestStack!(target); + assert.strictEqual(result?.number, 3); + // A merged layer is not fenced on, so its pull is never read. + assert.deepStrictEqual( + result?.layers.map((layer) => [layer.number, layer.headSha]), + [ + [6, undefined], + [7, headOid], + ], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads activity with the host's edit and resolve permissions", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/commits": { + items: [], + truncated: false, + next: null, + headOid, + }, + "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const activity = yield* provider.getChangeRequestActivity(target); + assert.strictEqual(activity.comments[0]?.canEdit, true); + assert.strictEqual(activity.reviewThreads[0]?.canResolve, true); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the viewer's permission to edit the pull request", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { + ...pull, + capabilities: { ...pull.capabilities, edit: false }, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const permissions = yield* provider.getViewerPermissions(target); + assert.strictEqual(permissions.editChangeRequest, false); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the diff from the pull request's changed files", () => { + const file = { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + }; + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": { + ...snapshot, + items: [ + { + ...file, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const diff = yield* provider.getDiff(target); + assert.include(diff.patch, "-old\n+new\n"); + assert.isNull(diff.nextCursor); + // The batched hunk read is a POST, but it writes nothing. + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("comments and replies to a thread on the pull request's comments", () => { + const written = { id: "c1", threadId: "root", version: 1 }; + const server = fakeGitCafe({ + "POST /repos/owner/repo/pulls/7/comments/": written, + "POST /repos/owner/repo/pulls/7/comments/root/replies": written, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.comment({ ...target, body: "Hello" }); + yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/", + "POST /repos/owner/repo/pulls/7/comments/root/replies", + ]); + assert.deepStrictEqual( + server.sent.map((request) => request.body), + [{ body: "Hello" }, { body: "Reply" }], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("resolves a thread through its root at the root's version", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "POST /repos/owner/repo/pulls/7/comments/root/resolve": { + id: "root", + threadId: "root", + version: 3, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/root/resolve", + ]); + assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("submits a review against the pull request's current head", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.submitReview({ + ...target, + verdict: "request-changes", + body: "Please fix", + comments: [], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + verdict: "request_changes", + body: "Please fix", + commitOid: headOid, + }); + assertRequestId(server.sent.at(-1)?.body); + + // GitCafe rejects a null body; an empty review leaves it out. + yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] }); + assert.notProperty(server.sent.at(-1)?.body as object, "body"); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["completed", true], + ["failed", false], + ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .runAction({ ...target, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, succeeds ? "Success" : "Failure"); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + strategy: "squash", + headOid, + expectedVersion: 4, + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "lands a stack through a layer only while its heads are the ones the reader saw", + () => { + const layers = [ + { number: 6, headOid: baseOid }, + { number: 7, headOid }, + ]; + const server = fakeGitCafe({ + ...stack(layers), + "POST /repos/owner/repo/pulls/stacks/3/land-through": { + id: "land-1", + state: "completed", + landedCount: 2, + stepCount: 2, + stopReason: null, + error: null, + steps: [], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid })); + const stale = yield* provider + .runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }], + }) + .pipe(Effect.result); + assert.strictEqual(stale._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + + yield* provider.runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: heads, + mergeMethod: "merge", + }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/stacks/3/land-through", + ]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + expectedRevision: 5, + throughPullRequestNumber: 7, + strategy: "merge", + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("restacks from a layer on update-branch", () => { + const server = fakeGitCafe({ + ...stack([{ number: 7, headOid }]), + "POST /repos/owner/repo/pulls/stacks/3/restack": { + id: "restack-1", + state: "completed", + stepCount: 1, + completedStepCount: 1, + pauseReason: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ + ...target, + action: "update-branch", + updateMethod: "rebase", + stackNumber: 3, + expectedStackHeads: [{ number: 7, headSha: headOid }], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]); + const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown }; + assertRequestId({ requestId }); + assert.deepStrictEqual(body, { expectedRevision: 5 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["authored", "authors"], + ["reviewing", "reviewers"], + ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { + actors: [{ actorId: "act_one", handle: "Alice" }], + }, + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]'); + assert.strictEqual( + page.items[0]?.reviewRequestLogins.includes("alice"), + involvement === "reviewing", + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("lists nothing for a viewer GitCafe has no actor for", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { actors: [] }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "authored", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual(page.items, []); + assert.deepStrictEqual( + server.sent.map((request) => request.path), + ["/repos/owner/repo/filter-options"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["authored", "reviewing"] as const)( + "lists %s unnarrowed when GitCafe can't say who the viewer is", + (involvement) => { + // No `filter-options` route: the fake answers 404, which is an ordinary failure. + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); + assert.isDefined(listing); + assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); + // An unnarrowed row says nothing about who was asked to review it. + assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("reads line counts, open merge strategies and requested reviewers", () => { + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const files = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` })); + let changePage = 0; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { + conflicts: "unknown", + fastForward: true, + strategies: ["merge", "squash", "rebase"], + blockers: [{ blockedStrategies: ["squash"] }, {}], + }, + checks: { pending: 0, failing: 0, total: 0 }, + }, + // Checks continue on a cursor too; a failure on a later page must not be dropped. + [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ + items: [ + { + name: request.query.has("after") ? "late" : "early", + status: "completed", + conclusion: request.query.has("after") ? "failure" : "success", + summary: null, + detailsUrl: null, + }, + ], + next: request.query.has("after") ? null : "checks-1", + }), + // `/changes` counts no lines, and a pull past one page continues on a cursor. + "GET /repos/owner/repo/pulls/7/changes": () => + changePage++ === 0 + ? { ...snapshot, items: files(0, 2), next: "cursor-1" } + : { ...snapshot, items: files(2, 1), next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + ...snapshot, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: 2, + deletions: 1, + })), + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const detail = yield* provider.getChangeRequest(target); + assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 }); + assert.strictEqual( + server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"), + "cursor-1", + ); + assert.deepStrictEqual(detail.mergeCapabilities, { + merge: true, + squash: false, + rebase: true, + }); + assert.deepStrictEqual( + detail.checks.map((check) => check.name), + ["early", "late"], + ); + // A requested reviewer shows before they have reviewed. + assert.deepStrictEqual( + detail.reviewers.map((reviewer) => reviewer.login), + ["alice"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("fences a native merge on the target branch's current commit", () => { + const branchOid = "fedcba9876543210fedcba9876543210fedcba98"; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null }, + "GET /repos/owner/repo/commit": { oid: branchOid }, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ ...target, action: "merge" }); + const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit"); + assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main"); + assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses to drop a reaction whose id it cannot address", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/reactions/": { + items: [ + { + subject: { kind: "pull_request_comment", id: "root" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: "../escape", + reactors: [actor], + }, + ], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps GitCafe's explanation short and on one line", () => { + const server = fakeGitCafe( + { + "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` }, + }, + { status: 409 }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getChangeRequest(target).pipe(Effect.flip); + assert.notInclude(error.detail, "\n"); + assert.isAtMost(error.detail.length, 310); + assert.isTrue(error.detail.startsWith("Line x")); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts new file mode 100644 index 000000000000..7ea6a1e6eb87 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -0,0 +1,867 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCapabilities, + type PullRequestStackHead, +} from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; +import * as Json from "./gitCafePullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + // Only through a stack: GitCafe's one branch update restacks every layer above it. + updateMethods: ["rebase"], + search: true, + stacks: true, + stackActions: true, + reactions: true, + labels: false, + // Line comments go through GitCafe's review drafts, which arrive with revision fencing. + review: { + inlineComment: false, + reply: true, + resolve: true, + verdicts: ["approve", "request-changes", "comment"], + }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: true }, +}; + +const PAGE_SIZE = 100; +/** Conversation pages read before the list is reported as truncated. */ +const MAX_PAGES = 10; +/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ +const DIFF_FILES_BATCH = 64; +/** + * Line totals cost one hunk read per batch, so a pull request larger than this reports none + * rather than reading every file. + */ +const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; +/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ +const MAX_CHANGE_PAGES = 20; + +const chunk = (items: ReadonlyArray, size: number): Array> => + Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); + +/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ +const detailablePaths = (files: ReadonlyArray) => + files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); + +/** + * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without + * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows + * GitCafe's own reason. + */ +const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => + status.merge.permitted !== false && + status.merge.strategies.includes(strategy) && + !(status.merge.blockers ?? []).some((blocker) => + (blocker.blockedStrategies ?? []).includes(strategy), + ); + +const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)); +const isId = Schema.is(Id); +const Principal = Schema.Struct({ handle: TrimmedNonEmptyString }); +const Written = Schema.Struct({ id: Id }); +const Versioned = Schema.Struct({ version: NonNegativeInt }); +const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt }); +const Status = Schema.Struct({ + merge: Schema.Struct({ + conflicts: Schema.Literals(["unknown", "conflicting"]), + fastForward: Schema.NullOr(Schema.Boolean), + strategies: Schema.Array(Schema.String), + permitted: Schema.optional(Schema.Boolean), + blockers: Schema.optional( + Schema.Array( + Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }), + ), + ), + }), + checks: Schema.Struct({ + pending: NonNegativeInt, + failing: NonNegativeInt, + total: NonNegativeInt, + }), +}); +const FilterOptions = Schema.Struct({ + actors: Schema.Array( + Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }), + ), +}); +const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const Snapshot = { + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + comparisonBaseOid: TrimmedNonEmptyString, +}; +const Changes = Schema.Struct({ + ...Snapshot, + items: Json.GitCafeDiffFiles, + next: Schema.NullOr(TrimmedNonEmptyString), +}); +const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles }); +const DiffCursor = Schema.Struct({ + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + baseOid: TrimmedNonEmptyString, + after: TrimmedNonEmptyString, +}); +const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor)); +const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor)); +const MergeOutcome = Schema.Struct({ + id: Schema.String, + state: Schema.Literals(["accepted", "completed", "failed"]), + reason: Schema.optional(Schema.String), +}); +const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); +/** Where a stack operation stopped short; anything else is underway or done. */ +const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); + +type PullRef = ProviderRepositoryRef & { readonly number: number }; +type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; + +/** The heads a reader saw are still every unmerged layer the action moves. */ +function stackHeadsCurrent( + stack: Json.GitCafeStack, + input: { readonly number: number; readonly merge: boolean }, + expected: ReadonlyArray, +): boolean { + const members = stack.members.toSorted((a, b) => a.position - b.position); + const index = members.findIndex((member) => member.pullRequestNumber === input.number); + if (index === -1) return false; + const moved = (input.merge ? members.slice(0, index + 1) : members).filter( + (member) => member.state !== "merged", + ); + return ( + moved.length === expected.length && + moved.every( + (member) => + member.headOid !== undefined && + expected.some( + (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid, + ), + ) + ); +} + +/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ +const withDetails = ( + files: ReadonlyArray, + details: ReadonlyArray, +) => { + const byPath = new Map(details.map((file) => [file.path, file])); + return files.map((file) => byPath.get(file.path) ?? file); +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ + const repositoryPath = ( + operation: string, + input: ProviderRepositoryRef, + ): Effect.Effect => + GitCafeHosts.isGitCafeHost(input.host) && + GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository + ? Effect.succeed(`/repos/${input.repository}`) + : Effect.fail( + failure( + operation, + `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`, + ), + ); + + const send = ( + operation: string, + input: Pick, + request: Omit, + ) => + api.request({ ...request, host: input.host, operation }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: error.reason, + detail: error.detail, + cause: error, + }), + ), + ); + + const read = >( + operation: string, + input: Pick, + request: Omit, + schema: S, + ) => + send(operation, input, request).pipe( + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + failure(operation, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + + const pullPath = (operation: string, input: PullRef) => + repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`)); + + const readPull = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), + ); + + /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ + const readPages = , unknown, never, never>>( + operation: string, + input: Pick, + path: string, + schema: S, + ) => + Effect.gen(function* () { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read( + operation, + input, + { path: `${path}?${query}` }, + schema, + ); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); + + const readComments = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => + readPages(operation, input, `${path}/comments`, Json.GitCafeComments), + ), + ); + + /** + * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no + * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + */ + const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( + operation: string, + input: PullRef, + ) { + const path = yield* pullPath(operation, input); + const { stack } = yield* read( + operation, + input, + { path: `${path}/stack` }, + Json.GitCafeStackEnvelope, + ); + if (stack === null) return null; + const members = yield* Effect.forEach( + stack.members, + (member) => + member.state === "merged" + ? Effect.succeed(member) + : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe( + Effect.map((pull) => + pull.headOid == null ? member : { ...member, headOid: pull.headOid }, + ), + ), + { concurrency: 4 }, + ); + return { ...stack, members }; + }); + + /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( + input: PullRef, + revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, + paths: ReadonlyArray, + ) { + if (paths.length === 0) return []; + const path = yield* pullPath("getDiff", input); + const files = yield* read( + "getDiff", + input, + { + method: "POST", + path: `${path}/diff-files`, + body: { paths, expectedVersion: revision.version }, + }, + DiffFiles, + ); + if ( + files.version !== revision.version || + files.headOid !== revision.headOid || + files.comparisonBaseOid !== revision.baseOid + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + return files.items; + }); + + const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* ( + input: Parameters[0] & { readonly stackNumber: number }, + ) { + const land = input.action === "merge"; + const operation = land ? "landStackThrough" : "restackStack"; + if (!land && input.action !== "update-branch") + return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`); + const stack = yield* readStack(operation, input); + if ( + stack === null || + stack.number !== input.stackNumber || + input.expectedStackHeads === undefined || + !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads) + ) + return yield* failure( + operation, + "The stack changed since it was read. Refresh it and try again.", + ); + const base = yield* repositoryPath(operation, input); + const outcome = yield* read( + operation, + input, + { + method: "POST", + path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`, + body: land + ? { + expectedRevision: stack.revision, + requestId: yield* randomUuidV4, + throughPullRequestNumber: input.number, + strategy: input.mergeMethod ?? "merge", + } + : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 }, + }, + StackOutcome, + ); + if (FAILED_STACK_STATES.has(outcome.state)) + return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); + }); + + /** + * Added and deleted line totals and the file count. `/changes` lists files without counting + * lines, so the counts come from batched hunk reads, and a pull request past the cap reports + * none rather than paying for every file. + */ + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + version: number, + ) { + const path = yield* pullPath("getChangeRequest", input); + const files: Array = []; + let revision: { version: number; headOid: string; baseOid: string } | undefined; + let after: string | null = null; + for (let page = 0; page < MAX_CHANGE_PAGES; page++) { + const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + if (after !== null) query.set("after", after); + const batch: typeof Changes.Type = yield* read( + "getChangeRequest", + input, + { path: `${path}/changes?${query}` }, + Changes, + ); + revision ??= { + version: batch.version, + headOid: batch.headOid, + baseOid: batch.comparisonBaseOid, + }; + files.push(...batch.items); + const previous: string | null = after; + after = batch.next; + if (after === null || after === previous || batch.items.length === 0) break; + } + // A hunk read that loses a race with a push leaves the counts at zero rather than dropping + // the file count with them. + const counted = + files.some((file) => file.additions !== undefined) || + files.length > LINE_STATS_MAX_FILES || + revision === undefined + ? files + : (yield* Effect.forEach( + chunk(detailablePaths(files), DIFF_FILES_BATCH), + (paths) => readDiffFiles(input, revision, paths), + { concurrency: 4 }, + ).pipe(Effect.orElseSucceed(() => [[]]))).flat(); + return { + changedFiles: files.length, + additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), + deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), + }; + }); + + const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( + input: Parameters[0], + ) { + const pull = yield* readPull("merge", input); + if (pull.state === "merged") return; + if (pull.mergeRoute === "unsupported") + return yield* failure("merge", "This GitCafe pull request has no supported merge route."); + const base = yield* repositoryPath("merge", input); + // GitCafe observes the base only for merges it routes to the provider; a native merge is + // fenced on the target branch's current commit. + const baseOid = + pull.mergeRoute === "provider" + ? pull.observedBaseOid + : (yield* read( + "merge", + input, + { + path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`, + }, + BranchCommit, + )).oid; + if (pull.headOid === null || baseOid == null) + return yield* failure( + "merge", + "GitCafe has not reported the pull request's head and base yet. Refresh and try again.", + ); + const path = yield* pullPath("merge", input); + const outcome = yield* read( + "merge", + input, + { + method: "POST", + path: `${path}/merge`, + body: { + requestId: yield* randomUuidV4, + expectedVersion: pull.version, + headOid: pull.headOid, + baseOid, + strategy: input.mergeMethod ?? "merge", + }, + }, + MergeOutcome, + ); + // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. + if (outcome.state === "failed") + return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); + }); + + const provider: PullRequestProviderApi = { + kind: "gitcafe", + capabilities: CAPABILITIES, + getViewer: (input) => + read( + "getViewer", + { host: input.host ?? "git.cafe" }, + { path: "/auth/principal" }, + Principal, + ).pipe(Effect.map((principal) => principal.handle)), + listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")( + function* (input) { + const base = yield* repositoryPath("listChangeRequests", input); + const involved = input.involvement === "authored" || input.involvement === "reviewing"; + let actorId: string | undefined; + if (involved) { + // A host narrows as far as it can: when the actor list can't be read, the listing comes + // back unnarrowed and the page's own involvement filter does the rest. A refused token + // or a rate limit still fails, since the service acts on those for the whole host. + const options = yield* read( + "listChangeRequests", + input, + { path: `${base}/filter-options` }, + FilterOptions, + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PullRequestProviderError: (error) => + error.reason === "unauthenticated" || error.reason === "rate-limited" + ? Effect.fail(error) + : Effect.succeed(Option.none()), + }), + ); + if (Option.isSome(options)) { + actorId = options.value.actors.find( + (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), + )?.actorId; + // A viewer GitCafe knows no actor for has authored and been asked to review nothing. + if (actorId === undefined) return { items: [], truncated: false, continues: false }; + } + } + const limit = Math.max(1, input.limit); + const items: Array = []; + let next: string | null = null; + do { + const query = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - items.length)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (input.query?.trim()) query.set("q", input.query.trim()); + if (actorId !== undefined) + query.set( + input.involvement === "reviewing" ? "reviewers" : "authors", + encodeActorIds([actorId]), + ); + if (next !== null) query.set("after", next); + const page: typeof Json.GitCafePulls.Type = yield* read( + "listChangeRequests", + input, + { path: `${base}/pulls?${query}` }, + Json.GitCafePulls, + ); + items.push(...page.items); + next = page.items.length === 0 ? null : page.next; + } while (next !== null && items.length < limit); + return { + items: items.slice(0, limit).map((pull) => { + const item = Json.toChangeRequest(pull, input); + // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An + // unnarrowed listing (no actor to filter by) says nothing about who was asked. + return input.involvement === "reviewing" && actorId !== undefined + ? { + ...item, + reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])], + } + : item; + }), + truncated: next !== null, + continues: false, + }; + }, + ), + getChangeRequestSummary: (input) => + readPull("getChangeRequestSummary", input).pipe( + Effect.map((pull) => ({ + ...Json.toChangeRequest(pull, input), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + })), + ), + getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) { + const pull = yield* readPull("getChangeRequest", input); + const path = yield* pullPath("getChangeRequest", input); + const base = yield* repositoryPath("getChangeRequest", input); + const [status, checks, changes] = yield* Effect.all( + [ + read("getChangeRequest", input, { path: `${path}/status` }, Status), + pull.headOid === null + ? Effect.succeed(null) + : readPages( + "getChangeRequest", + input, + `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`, + Json.GitCafeChecks, + ), + pull.headOid === null + ? Effect.succeed(null) + : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: 3 }, + ); + const permissions = Json.toViewerPermissions(pull); + return { + ...Json.toChangeRequest(pull, input), + headSha: pull.headOid, + body: pull.description ?? "", + ...(pull.sourceRepo === null + ? {} + : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }), + additions: changes?.additions ?? 0, + deletions: changes?.deletions ?? 0, + changedFiles: changes?.changedFiles ?? 0, + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + reviewers: Json.toReviewers(pull, input.host), + checks: checks === null ? [] : Json.toChecks(checks), + ...(status.checks.total === 0 + ? {} + : { + checksState: + status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const), + }), + mergeability: + status.merge.conflicts === "conflicting" + ? ("conflicting" as const) + : ("unknown" as const), + baseComparison: + status.merge.fastForward === null + ? ("unknown" as const) + : status.merge.fastForward + ? ("up-to-date" as const) + : ("behind" as const), + mergeCapabilities: { + merge: mergeStrategyOpen(status, "merge"), + squash: mergeStrategyOpen(status, "squash"), + rebase: mergeStrategyOpen(status, "rebase"), + }, + viewerPermissions: + status.merge.permitted === false + ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") } + : permissions, + }; + }), + getChangeRequestStack: (input) => + readStack("getChangeRequestStack", input).pipe( + Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))), + ), + getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")( + function* (input) { + const pull = yield* readPull("getChangeRequestActivity", input); + const path = yield* pullPath("getChangeRequestActivity", input); + const operation = "getChangeRequestActivity"; + const [comments, reviews, commits, reactions] = yield* Effect.all( + [ + readComments(operation, input), + readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews), + pull.headOid === null + ? Effect.succeed({ items: [], next: null }) + : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), + // Reactions decorate the conversation; a failed read leaves them out, not it. + readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: 4 }, + ); + return Json.toActivity({ + comments, + reviews, + commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" }, + host: input.host, + ...(pull.headOid === null ? {} : { headOid: pull.headOid }), + ...(reactions === undefined ? {} : { reactions }), + }); + }, + ), + getViewerPermissions: (input) => + readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), + getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { + if (input.commit !== undefined) + return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), + ); + const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; + const path = yield* pullPath("getDiff", input); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(DIFF_FILES_BATCH), + }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (cursor !== null && + (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + return { + ...Json.toDiff({ items: withDetails(page.items, details) }), + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }), + runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { + if (input.stackNumber !== undefined) + return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); + switch (input.action) { + case "merge": + return yield* merge(input); + case "ready": + case "draft": + case "close": + case "reopen": { + const pull = yield* readPull(input.action, input); + const path = yield* pullPath(input.action, input); + yield* send(input.action, input, { + method: "POST", + path: `${path}/${input.action}`, + body: { expectedVersion: pull.version }, + }); + return; + } + default: + return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`); + } + }), + updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")( + function* (input) { + const pull = yield* readPull("updateChangeRequest", input); + const path = yield* pullPath("updateChangeRequest", input); + yield* read( + "updateChangeRequest", + input, + { + method: "PATCH", + path, + body: { + expectedVersion: pull.version, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }, + }, + Versioned, + ); + }, + ), + comment: (input) => + pullPath("comment", input).pipe( + Effect.flatMap((path) => + read( + "comment", + input, + { method: "POST", path: `${path}/comments/`, body: { body: input.body } }, + Written, + ), + ), + Effect.asVoid, + ), + updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) { + if (!isId(input.commentId)) + return yield* failure("updateComment", "GitCafe comment ids are opaque slugs."); + const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`; + const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail); + yield* read( + "updateComment", + input, + { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } }, + Written, + ); + }), + submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) { + if (input.comments.length > 0) + return yield* failure("submitReview", "GitCafe line comments are not supported yet."); + const pull = yield* readPull("submitReview", input); + if (pull.headOid === null) + return yield* failure("submitReview", "GitCafe has not reported the pull request's head."); + const path = yield* pullPath("submitReview", input); + yield* read( + "submitReview", + input, + { + method: "POST", + path: `${path}/reviews`, + body: { + verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict, + ...(input.body.length === 0 ? {} : { body: input.body }), + commitOid: pull.headOid, + expectedVersion: pull.version, + requestId: yield* randomUuidV4, + }, + }, + Written, + ); + }), + listReviewerCandidates: () => + Effect.fail( + failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."), + ), + setReviewerRequest: () => + Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")), + replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) { + if (!isId(input.threadId)) + return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs."); + const path = yield* pullPath("replyToThread", input); + yield* read( + "replyToThread", + input, + { + method: "POST", + path: `${path}/comments/${input.threadId}/replies`, + body: { body: input.body }, + }, + Written, + ); + }), + setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) { + const path = `${yield* pullPath("setReaction", input)}/reactions/`; + const subject = + input.subjectId === undefined + ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id } + : { kind: "pull_request_comment" as const, id: input.subjectId }; + const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions); + const mine = reactions.items.find( + (item) => + item.subject.kind === subject.kind && + item.subject.id === subject.id && + item.emoji.kind === "unicode" && + Json.reactionContent(item.emoji.value) === input.content, + )?.viewerReactionId; + if (input.reacted === (mine != null)) return; + if (input.reacted) { + yield* send("setReaction", input, { + method: "POST", + path, + body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } }, + }); + return; + } + if (mine == null) return; + if (!isId(mine)) + return yield* failure("setReaction", "GitCafe returned an unreadable reaction id."); + yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` }); + }), + setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")( + function* (input) { + const comments = yield* readComments("setThreadResolution", input); + const root = comments.items.find( + (comment) => comment.id === input.threadId && comment.threadId === input.threadId, + ); + if (root === undefined || root.version === undefined || !isId(root.id)) + return yield* failure("setThreadResolution", "GitCafe did not return the thread root."); + if (input.resolved === (root.resolvedAt !== null)) return; + const path = yield* pullPath("setThreadResolution", input); + yield* read( + "setThreadResolution", + input, + { + method: "POST", + path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`, + body: { expectedVersion: root.version }, + }, + Written, + ); + }, + ), + }; + return provider; +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts new file mode 100644 index 000000000000..aaaf5d0e90ff --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -0,0 +1,339 @@ +/** + * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: + * + * - `discovery: SourceControlCliDiscoverySpec` — kind `"gitcafe"`, executable `"cafe"`, + * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a + * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its + * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. + * - `make: Effect` with + * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. + * + * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. + */ +import { assert, describe, it } from "@effect/vitest"; +import { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ + stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), + stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), +}); +const failure = (code: string, status: number | null) => ({ + schemaVersion: 1, + error: { code, status, message: `Failure ${code}` }, +}); + +describe("GitCafeSourceControlProvider", () => { + it("probes the cafe CLI against production", () => { + const { discovery } = GitCafeSourceControlProvider; + assert.strictEqual(discovery.kind, "gitcafe"); + assert.strictEqual(discovery.executable, "cafe"); + assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); + assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); + }); + + it("reads a signed-in account and keeps transient failures apart from sign-out", () => { + const { parseAuth } = GitCafeSourceControlProvider.discovery; + const signedIn = parseAuth( + authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), + ); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice")); + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) + .status, + "unauthenticated", + ); + for (const [code, status] of [ + ["FORBIDDEN", 403], + ["NETWORK_ERROR", null], + ] as const) { + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, + "unknown", + ); + } + }); + + it.effect.each([ + ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + const context = { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + assert.strictEqual(provider.kind, "gitcafe"); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), + { + nameWithOwner: "team/project", + url: `https://${host}/team/project`, + sshUrl: `ssh@${host}:team/project.git`, + }, + ); + assert.deepStrictEqual(urls, [ + `https://${host}/api/repos/team/project`, + `https://${host}/api/repos/team/project`, + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(TestSourceControlHost.layer()), + Layer.provide( + Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect( + "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", + () => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + // The checkout has no remote at all, as when a repository is about to be created. + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), + { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }, + ); + const foreign = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://example.com/team/project/pulls/42", + }) + .pipe(Effect.flip); + assert.strictEqual(foreign._tag, "SourceControlProviderError"); + assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }, + ); + + it.effect.each([ + ["no context", undefined, "https://git.cafe/fork/project.git"], + ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], + ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], + ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], + [ + "an HTTPS remote with a user", + "https://alice@git.cafe/team/project.git", + "https://git.cafe/fork/project.git", + ], + ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { + const remotes: Array = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid: null, + updatedAt: null, + sourceRepo: { owner: "fork", name: "project" }, + closedAt: null, + mergedAt: null, + }), + ), + ), + ), + ); + const context = + remoteUrl === undefined + ? undefined + : { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: "https://git.cafe/team/project/pulls/7", + ...(context === undefined ? {} : { context }), + }); + assert.deepStrictEqual(remotes, [expected]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + ensureRemote: (input) => { + remotes.push(input.url); + return Effect.succeed("gitcafe"); + }, + listLocalBranchNames: () => Effect.succeed([]), + fetchRemoteBranch: () => Effect.void, + setBranchUpstream: () => Effect.void, + switchRef: () => Effect.succeed({ refName: null }), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect("waits for a created repository's admission before handing out its URLs", () => { + const urls: Array = []; + const states = ["running", "complete"]; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + repoId: "repo_1", + state: states.shift(), + owner: "team", + name: "project", + }), + ), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + const created = yield* provider + .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(created), { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }); + assert.deepStrictEqual(urls, [ + "https://git.cafe/api/orgs/team/admissions/repo_1", + "https://git.cafe/api/orgs/team/admissions/repo_1", + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput( + encodeJson({ + schemaVersion: 1, + data: { + resource: { + repoId: "repo_1", + state: "running", + owner: "team", + name: "project", + }, + }, + }), + ), + ), + }, + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts new file mode 100644 index 000000000000..5d55bd72bac3 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -0,0 +1,503 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import { + PositiveInt, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, +} from "@t3tools/contracts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +/** Discovery reports the production account; repository operations pick their own host. */ +const AUTH_HOST = "git.cafe"; +/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ +const ADMISSION_TIMEOUT = "30 seconds"; +const ADMISSION_POLL_INTERVAL = "500 millis"; +const cliArgs = (host: string) => [ + "--host", + `https://${host}/api`, + "--no-input", + "--no-update-check", +]; + +const decodeAuth = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }), + }), + ), +); +/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ +const decodeFailure = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + status: Schema.NullOr(Schema.Finite), + }), + }), + ), +); +const cliFailure = (output: string) => + [output.trim(), ...output.split(/\r?\n/u).toReversed()] + .map((line) => decodeFailure(line.trim())) + .find(Result.isSuccess)?.success.error; + +export const discovery = { + type: "cli", + kind: "gitcafe", + label: "GitCafe", + executable: "cafe", + versionArgs: ["--version"], + authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"], + parseAuth: (input) => { + const signedIn = decodeAuth(input.stdout.trim()); + if (input.exitCode === 0 && Result.isSuccess(signedIn)) + return providerAuth({ + status: "authenticated", + host: AUTH_HOST, + account: signedIn.success.data.username, + }); + const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); + if (failure) + return providerAuth({ + // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + status: + failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 + ? "unauthenticated" + : "unknown", + host: AUTH_HOST, + detail: failure.message, + }); + return providerAuth({ + status: "unknown", + host: AUTH_HOST, + detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, + }); + }, + installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`, +} satisfies SourceControlCliDiscoverySpec; + +const Repository = Schema.Struct({ + name: TrimmedNonEmptyString, + defaultBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +const Pull = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + sourceBranch: TrimmedNonEmptyString, + targetBranch: TrimmedNonEmptyString, + headOid: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), +}); +const PullDetail = Schema.Struct({ + ...Pull.fields, + sourceRepo: Schema.NullOr( + Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + ), + closedAt: Schema.NullOr(Schema.String), + mergedAt: Schema.NullOr(Schema.String), +}); +const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) }); +const RepositoryAdmission = Schema.Struct({ + repoId: TrimmedNonEmptyString, + state: Schema.String, + owner: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +const CreatedRepository = Schema.Struct({ + data: Schema.Struct({ resource: RepositoryAdmission }), +}); +const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +interface Target { + readonly host: GitCafeHosts.GitCafeHost; + readonly repository: string; +} + +const cloneUrls = (target: Target) => ({ + nameWithOwner: target.repository, + url: `https://${target.host}/${target.repository}`, + sshUrl: `ssh@${target.host}:${target.repository}.git`, +}); + +/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ +const isSshRemote = (remoteUrl: string | undefined) => + remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); + +const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; + +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + const host = yield* SourceControlHost.SourceControlHost; + + const error = (operation: string, cwd: string, detail: string, cause?: unknown) => + new SourceControlProviderError({ + provider: "gitcafe", + operation, + cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const read = >( + operation: string, + cwd: string, + request: Omit, + schema: S, + ) => + api.request({ ...request, operation }).pipe( + Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)), + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + error(operation, cwd, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => + host.process + .run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json" }, + timeoutMs: 30_000, + }) + .pipe( + Effect.mapError((cause) => + error( + operation, + cwd, + (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || + "GitCafe CLI command failed.", + cause, + ), + ), + ); + + /** The repository a call names, falling back to the checkout's own GitCafe remote. */ + const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( + operation: string, + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + ) { + const named = + input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); + if (named) return named; + // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no + // remote (a repository being created) still resolves, on git.cafe. + const readRemote = host.git + .resolvePrimaryRemoteName(input.cwd) + .pipe( + Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)), + ); + const remoteUrl = + input.context?.remoteUrl ?? + (input.repository === undefined + ? yield* readRemote.pipe( + Effect.mapError((cause) => + error(operation, input.cwd, "Could not read the GitCafe remote.", cause), + ), + ) + : yield* readRemote.pipe(Effect.orElseSucceed(() => null))); + const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null; + if (input.repository === undefined) { + if (remote) return remote; + return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); + } + // A bare owner/name lives on the checkout's own GitCafe host. + const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); + if (repository !== null) + return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; + return yield* error( + operation, + input.cwd, + "A GitCafe repository is named owner/name or by its git.cafe URL.", + ); + }); + + const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) { + const match = pullUrlPattern.exec(input.reference.trim()); + const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const target: Target = + urlHost && match?.[2] + ? { host: urlHost, repository: match[2] } + : yield* resolveTarget("getChangeRequest", input); + // A pull request URL on another host is not a GitCafe pull request number. + const number = match + ? urlHost + ? match[3] + : undefined + : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; + if (number === undefined) + return yield* error( + "getChangeRequest", + input.cwd, + "A GitCafe pull request is a number or a git.cafe pull request URL.", + ); + const pull = yield* read( + "getChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls/${number}` }, + PullDetail, + ); + const owner = target.repository.split("/")[0] ?? null; + return { + provider: "gitcafe", + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + baseRefName: pull.targetBranch, + headRefName: pull.sourceBranch, + ...(pull.headOid ? { headSha: pull.headOid } : {}), + state: pull.state, + ...(pull.draft ? { isDraft: true } : {}), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + updatedAt: pull.updatedAt, + isCrossRepository: pull.sourceRepo !== null, + headRepositoryNameWithOwner: pull.sourceRepo + ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` + : target.repository, + headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner, + } satisfies ChangeRequest; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitcafe", + repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, + getChangeRequest: getPull, + listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( + function* (input) { + const target = yield* resolveTarget("listChangeRequests", input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = input.limit ?? 20; + const items: Array = []; + let after: string | null = null; + do { + const query = new URLSearchParams({ + sourceBranches: encodeBranches([branch]), + limit: String(Math.min(limit, 100)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (after !== null) query.set("after", after); + const page: typeof Pulls.Type = yield* read( + "listChangeRequests", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls?${query}` }, + Pulls, + ); + for (const pull of page.items) { + if (pull.sourceBranch !== branch || items.length >= limit) continue; + // List rows omit fork identity, so only the branch's own rows are read in full. + const detail = yield* getPull({ + cwd: input.cwd, + reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + }); + if ( + (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || detail.headRepositoryOwnerLogin === source.owner) + ) + items.push(detail); + } + after = page.items.length === 0 || page.next === after ? null : page.next; + } while (after !== null && items.length < limit); + return items; + }, + ), + createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")( + function* (input) { + const target = yield* resolveTarget("createChangeRequest", { + ...input, + ...(input.target?.repository ? { repository: input.target.repository } : {}), + }); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const sourceRepository = + source?.repository ?? + (source?.owner + ? `${source.owner}/${target.repository.split("/")[1]}` + : target.repository); + const branch = SourceControlProvider.sourceBranch(input); + yield* cafe("createChangeRequest", input.cwd, target, [ + "pr", + "create", + "--json", + "--repo", + target.repository, + "--head", + sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, + "--base", + input.target?.refName ?? input.baseRefName, + "--title", + input.title, + "--body-file", + input.bodyFile, + ]); + }, + ), + getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const target = yield* resolveTarget("getRepositoryCloneUrls", input); + yield* read( + "getRepositoryCloneUrls", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return cloneUrls(target); + }, + ), + createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { + const target = yield* resolveTarget("createRepository", input); + const [owner = "", name = ""] = target.repository.split("/"); + const output = yield* cafe("createRepository", input.cwd, target, [ + "repo", + "create", + name, + "--org", + owner, + "--visibility", + input.visibility, + "--json", + ]); + const created = yield* decodeCreatedRepository(output.stdout).pipe( + Effect.mapError((cause) => + error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), + ), + ); + // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is + // still running, so wait for it to settle the way `cafe repo create --clone` does. + const settled = (state: string) => state === "complete" || state === "blocked"; + const pending = created.data.resource; + const resource = settled(pending.state) + ? pending + : yield* read( + "createRepository", + input.cwd, + { + host: target.host, + path: `/orgs/${pending.owner}/admissions/${pending.repoId}`, + }, + RepositoryAdmission, + ).pipe( + Effect.repeat({ + until: (admission) => settled(admission.state), + schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL), + }), + Effect.timeoutOption(ADMISSION_TIMEOUT), + Effect.map(Option.getOrElse(() => pending)), + ); + // Anything short of complete is not yet usable. + if (resource.state !== "complete") + return yield* error( + "createRepository", + input.cwd, + `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`, + ); + return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` }); + }), + getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) { + const target = yield* resolveTarget("getDefaultBranch", input); + const repository = yield* read( + "getDefaultBranch", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return repository.defaultBranch; + }), + // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const pull = yield* getPull(input); + const pullHost = new URL(pull.url).host; + const sourceRepository = pull.headRepositoryNameWithOwner; + if (!sourceRepository) + return yield* error( + "checkoutChangeRequest", + input.cwd, + "GitCafe did not name the pull request's source repository.", + ); + const contextRemote = input.context + ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl) + : null; + const git = host.git; + yield* Effect.gen(function* () { + const remoteName = + input.context && + contextRemote?.repository === sourceRepository && + contextRemote.host === pullHost + ? input.context.remoteName + : yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "gitcafe", + // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. + url: isSshRemote(input.context?.remoteUrl) + ? `ssh@${pullHost}:${sourceRepository}.git` + : `https://${pullHost}/${sourceRepository}.git`, + }); + const localBranch = pull.isCrossRepository + ? `pr-${pull.number}/${pull.headRefName}` + : pull.headRefName; + const branches = yield* git.listLocalBranchNames(input.cwd); + const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName }; + if (input.force !== true && !branches.includes(localBranch)) + yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); + else yield* git.fetchRemoteTrackingBranch(remoteBranch); + // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. + if (input.force === true) + yield* git.execute({ + operation: "GitCafeSourceControlProvider.checkoutChangeRequest", + cwd: input.cwd, + args: [ + "checkout", + "-B", + localBranch, + `refs/remotes/${remoteName}/${pull.headRefName}`, + "--", + ], + }); + yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + error( + "checkoutChangeRequest", + input.cwd, + "Could not check out the GitCafe pull request.", + cause, + ), + ), + ); + }, + ), + }); +}); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts new file mode 100644 index 000000000000..467614c2ec1c --- /dev/null +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull + * requests through GitCafe's REST API on git.cafe and staging.git.cafe. + * + * @module source-control-gitcafe/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitcafe", + make: Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + pullRequests: GitCafePullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts new file mode 100644 index 000000000000..a828a99f6ff1 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -0,0 +1,46 @@ +/** + * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): + * + * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` — the only hosts GitCafe lives on. + * - `isGitCafeHost(host: string): boolean` — case-insensitive membership in `GITCAFE_HOSTS`. + * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` + * — the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. + */ +import { assert, describe, it } from "@effect/vitest"; + +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +describe("gitCafeHosts", () => { + it("knows exactly the production and staging hosts", () => { + assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]); + assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe")); + assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe")); + for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) { + assert.isFalse(GitCafeHosts.isGitCafeHost(host), host); + } + }); + + it.each([ + ["https://git.cafe/team/project.git", "git.cafe"], + ["https://git.cafe/team/project", "git.cafe"], + ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ["git@staging.git.cafe:team/project.git", "staging.git.cafe"], + ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ])("reads the host and repository of %s", (url, host) => { + assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { + host, + repository: "team/project", + }); + }); + + it.each([ + "https://github.com/team/project.git", + "https://evil.git.cafe/team/project.git", + "https://git.cafe/team/nested/project.git", + "https://git.cafe/team", + "team/project", + ])("is not a GitCafe repository: %s", (url) => { + assert.isNull(GitCafeHosts.parseGitCafeRemote(url)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts new file mode 100644 index 000000000000..a04b759b2d5f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -0,0 +1,49 @@ +/** + * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no + * self-hosted installs, so the list is fixed rather than discovered. + * + * @module source-control-gitcafe/server/gitCafeHosts + */ + +export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; +export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; + +/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ +export function gitCafeHost(host: string): GitCafeHost | null { + const normalized = host.trim().toLowerCase(); + return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; +} + +export function isGitCafeHost(host: string): boolean { + return gitCafeHost(host) !== null; +} + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const REMOTE_PATTERNS = [ + /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^[^@/:]+@([^/:]+):(.+)$/u, +]; + +/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ +export function gitCafeRepositoryPath(path: string): string | null { + const repository = path + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + return REPOSITORY.test(repository) ? repository : null; +} + +/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ +export function parseGitCafeRemote( + url: string, +): { readonly host: GitCafeHost; readonly repository: string } | null { + for (const pattern of REMOTE_PATTERNS) { + const match = pattern.exec(url.trim()); + if (!match?.[1] || !match[2]) continue; + const host = gitCafeHost(match[1]); + const repository = gitCafeRepositoryPath(match[2]); + return host && repository ? { host, repository } : null; + } + return null; +} diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts new file mode 100644 index 000000000000..41d578cdf1e0 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -0,0 +1,547 @@ +/** + * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral + * `Provider*` shapes. Pass 2 creates these exports: + * + * Schemas (REST bodies as GitCafe sends them): + * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, + * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), + * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` + * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), + * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, + * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), + * `GitCafeChecks` (`{ items, next }`). + * + * Conversions (all take the repository host, so staging stays on staging): + * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` + * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`. + * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): + * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve` + * on review threads, from each comment's `capabilities`. + * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` + * - `toDiff(diff): ProviderDiffSlice` + * - `toChecks(checks): ReadonlyArray` + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts"; + +const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull); +const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail); +const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments); +const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews); +const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits); +const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions); +const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope); +const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff); +const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks); + +const timestamp = "2026-09-12T12:00:00Z"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: true, + sourceBranch: "feature", + targetBranch: "main", + headOid: "abcdef", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 1, +}; +const detail = { + ...pull, + draft: false, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const lineComment = { + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: "abcdef", + resolvedAt: null, +}; +const capabilities = (overrides: Record = {}) => ({ + edit: false, + hide: false, + unhide: false, + delete: false, + resolve: false, + unresolve: false, + ...overrides, +}); +const noReviews = decodeReviews({ items: [], next: null }); +const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null }); +const activity = ( + comments: ReadonlyArray>, + extra: { readonly reactions?: unknown; readonly host?: string } = {}, +) => + GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: comments, next: null }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: extra.host ?? "git.cafe", + ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }), + }); + +describe("toChangeRequest", () => { + it("reads draft state, the repository host's URLs and absolute avatars", () => { + const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(row.state, "open"); + assert.isTrue(row.isDraft); + assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.deepStrictEqual(row.author, { + login: "alice", + name: null, + avatarUrl: "https://staging.git.cafe/avatars/one", + }); + }); + + it("names linked GitHub reviewers by both logins and drops unavailable ones", () => { + const row = GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null }, + reviewers: [ + { actor }, + { + actor: { + kind: "github", + actorId: "act_github", + login: "upstream", + avatarUrl: null, + linkedProfile: { handle: "linked-local" }, + }, + }, + { actor: { kind: "unavailable", actorId: "act_deleted" } }, + ], + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.strictEqual(row.author?.login, "remote-user"); + assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); + }); + + it("requires capabilities on a detail response", () => { + assert.throws(() => decodePullDetail(pull)); + }); +}); + +describe("toViewerPermissions", () => { + const permissions = (overrides: Record) => + GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides })); + + it("lets an editor rewrite the title and description, and nobody else", () => { + assert.isTrue(permissions({}).editChangeRequest); + assert.isFalse( + permissions({ + capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true }, + }).editChangeRequest, + ); + }); + + it("offers lifecycle actions by state and never a standalone branch update", () => { + assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]); + assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]); + assert.deepStrictEqual(permissions({ state: "merged" }).actions, []); + assert.isTrue(permissions({}).stackRebase); + }); + + it("keeps a locked conversation's composer for moderators only", () => { + const locked = (moderate: boolean) => + permissions({ + lockedAt: timestamp, + capabilities: { comment: true, review: false, merge: false, edit: false, moderate }, + }); + assert.isFalse(locked(false).comment); + assert.isFalse(locked(false).resolve); + assert.isTrue(locked(true).comment); + assert.deepStrictEqual(locked(true).verdicts, []); + }); + + it("offers no composer to a viewer GitCafe won't let comment", () => { + const silent = permissions({ + capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true }, + }); + assert.isFalse(silent.comment); + assert.isFalse(silent.resolve); + }); +}); + +describe("toActivity", () => { + it("groups line comments by their persisted thread id and marks a paged list truncated", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ + items: [ + { ...lineComment, id: "reply", threadId: "thread-one" }, + { ...lineComment, id: "other", threadId: "thread-two" }, + ], + next: "next", + }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: "git.cafe", + }); + assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [ + "thread-one", + "thread-two", + ]); + assert.isTrue(result.commentsTruncated); + }); + + it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => { + const result = activity([ + { ...lineComment, id: "root", threadId: "root", resolvedAt }, + { ...lineComment, id: "reply", threadId: "root", resolvedAt: null }, + ]); + assert.strictEqual(result.reviewThreads.length, 1); + assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null); + assert.strictEqual(result.reviewThreads[0]?.comments.length, 2); + }); + + it("carries per-remark edit permission onto both copies of a remark", () => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + capabilities: capabilities({ edit: false, resolve: true }), + }, + { + ...lineComment, + id: "reply", + threadId: "root", + capabilities: capabilities({ edit: true }), + }, + { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null }, + ]); + const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; + assert.strictEqual(canEdit("root"), false); + assert.strictEqual(canEdit("reply"), true); + // A host that says nothing about a remark leaves the page to guess. + assert.isUndefined(canEdit("silent")); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), + [ + ["root", false], + ["reply", true], + ], + ); + }); + + it.each([ + [null, { resolve: true, unresolve: false }, true], + [null, { resolve: false, unresolve: true }, false], + [timestamp, { resolve: true, unresolve: false }, false], + [timestamp, { resolve: false, unresolve: true }, true], + ] as const)( + "reads canResolve for a thread resolved at %s with %o as %s", + (resolvedAt, allowed, canResolve) => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + resolvedAt, + capabilities: capabilities(allowed), + }, + ]); + assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve); + }, + ); + + it("leaves canResolve absent when the root says nothing", () => { + const result = activity([{ ...lineComment, id: "root", threadId: "root" }]); + assert.isUndefined(result.reviewThreads[0]?.canResolve); + }); + + it("maps review verdicts, dismissals and commits", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: [], next: null }), + reviews: decodeReviews({ + items: [ + { + id: "r1", + author: actor, + body: "LGTM", + verdict: "approve", + dismissedAt: null, + createdAt: timestamp, + }, + { + id: "r2", + author: actor, + body: null, + verdict: "request_changes", + dismissedAt: timestamp, + createdAt: "2026-09-12T13:00:00Z", + }, + ], + next: null, + }), + commits: decodeCommits({ + items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }], + headOid: "abcdef", + truncated: false, + next: null, + }), + host: "git.cafe", + }); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["r1", "review", "approved"], + ["r2", "review", "dismissed"], + ], + ); + assert.deepStrictEqual( + result.commits.map((commit) => commit.oid), + ["abcdef"], + ); + }); + + it("maps supported reactions to the pull request and to matching comments in both views", () => { + const result = activity( + [ + { ...lineComment, id: "comment-a", threadId: "comment-a" }, + { ...lineComment, id: "comment-b", threadId: "comment-a" }, + ], + { + reactions: { + next: null, + items: [ + { + subject: { kind: "pull_request_comment", id: "comment-b" }, + emoji: { kind: "unicode", value: "👍" }, + count: 3, + viewerReactionId: "reaction-viewer", + reactors: [actor, { kind: "unavailable", actorId: "deleted" }], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🚀" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + // Neither a custom emoji nor one outside the shared set is shown under another name. + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "custom", id: "party-parrot" }, + count: 2, + viewerReactionId: null, + reactors: [actor], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "🔥" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + ], + }, + }, + ); + const thumbsUp = { + content: "thumbs-up", + count: 3, + actors: ["alice"], + viewerHasReacted: true, + } as const; + assert.deepStrictEqual(result.reactions, [ + { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false }, + ]); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + }); +}); + +describe("toStack", () => { + it("orders members by position and reports heads only where GitCafe gives them", () => { + const { stack } = decodeStackEnvelope({ + stack: { + id: "stack-one", + number: 3, + revision: 1, + landingBase: "main", + members: [ + { + pullRequestNumber: 7, + title: "Top", + state: "open", + draft: true, + sourceBranch: "feature", + position: 2, + }, + { + pullRequestNumber: 6, + title: "Base", + state: "merged", + draft: false, + sourceBranch: "base", + headOid: "0123456789abcdef0123456789abcdef01234567", + position: 1, + }, + ], + }, + }); + assert.isNotNull(stack); + const result = GitCafePullRequestJson.toStack(stack!, { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3"); + assert.strictEqual(result.base, "main"); + assert.deepStrictEqual(result.layers, [ + { + number: 6, + title: "Base", + headBranch: "base", + state: "merged", + isDraft: false, + headSha: "0123456789abcdef0123456789abcdef01234567", + }, + { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true }, + ]); + }); + + it("reads an unstacked pull request as no stack", () => { + assert.isNull(decodeStackEnvelope({ stack: null }).stack); + }); +}); + +describe("toDiff", () => { + it("converts numeric hunk coordinates into a unified patch", () => { + const diff = decodeDiff({ + items: [ + { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + truncated: false, + }); + assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n"); + }); + + it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => { + const diff = decodeDiff({ + items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }], + truncated: false, + }); + const result = GitCafePullRequestJson.toDiff(diff); + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.omittedFileStats, [ + { path: "large.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("quotes renamed and binary paths", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "new image.png", + oldPath: "old image.png", + status: "renamed", + binary: true, + hunks: [], + }, + ], + truncated: false, + }), + ); + // Git quotes a name for the bytes a header can't carry, not for a space. + assert.include(patch, "rename from old image.png"); + assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); + }); + + it("C-escapes a name whose newline or tab would break the patch headers", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }], + truncated: false, + }), + ); + assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"'); + assert.notInclude(patch, "two\nlines"); + }); +}); + +describe("toChecks", () => { + it("maps GitCafe's status and conclusion onto the shared check states", () => { + const check = (status: string, conclusion: string | null) => ({ + name: `${status}-${conclusion}`, + status, + conclusion, + summary: null, + detailsUrl: null, + }); + const checks = GitCafePullRequestJson.toChecks( + decodeChecks({ + items: [ + check("queued", null), + check("in_progress", null), + check("completed", "success"), + check("completed", "action_required"), + check("completed", "timed_out"), + ], + next: null, + }), + ); + assert.deepStrictEqual( + checks.map((item) => item.status), + ["pending", "pending", "success", "action-required", "failure"], + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts new file mode 100644 index 000000000000..453083a7a6b4 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -0,0 +1,578 @@ +/** + * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every + * conversion takes the repository's host, so a staging repository's links stay on staging. + * + * @module source-control-gitcafe/server/gitCafePullRequestJson + */ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestComment, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewThread, + type PullRequestViewerPermissions, +} from "@t3tools/contracts"; +import type { + ProviderChangeRequest, + ProviderChangeRequestActivity, + ProviderChangeRequestStack, + ProviderDiffSlice, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const Actor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("local"), + actorId: Schema.String, + handle: TrimmedNonEmptyString, + displayName: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("github"), + actorId: Schema.String, + login: TrimmedNonEmptyString, + avatarUrl: Schema.NullOr(Schema.String), + linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), + }), + Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), +]); +type Actor = typeof Actor.Type; + +const PullState = Schema.Literals(["open", "closed", "merged"]); + +export const GitCafePull = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + targetBranch: Schema.String, + headOid: Schema.NullOr(Schema.String), + author: Actor, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + version: NonNegativeInt, + reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), + labels: Schema.optional( + Schema.Array( + Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), + ), + ), +}); +export type GitCafePull = typeof GitCafePull.Type; + +export const GitCafePulls = Schema.Struct({ + items: Schema.Array(GitCafePull), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafePullDetail = Schema.Struct({ + ...GitCafePull.fields, + description: Schema.NullOr(Schema.String), + closedAt: Schema.NullOr(IsoDateTime), + mergedAt: Schema.NullOr(IsoDateTime), + sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), + /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ + observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), + mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), + lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + capabilities: Schema.Struct({ + comment: Schema.Boolean, + review: Schema.Boolean, + merge: Schema.Boolean, + edit: Schema.Boolean, + moderate: Schema.Boolean, + }), +}); +export type GitCafePullDetail = typeof GitCafePullDetail.Type; + +const CommentCapabilities = Schema.Struct({ + edit: Schema.Boolean, + hide: Schema.Boolean, + unhide: Schema.Boolean, + delete: Schema.Boolean, + resolve: Schema.Boolean, + unresolve: Schema.Boolean, +}); + +const GitCafeComment = Schema.Struct({ + id: TrimmedNonEmptyString, + threadId: TrimmedNonEmptyString, + /** What a resolution or rewrite fences on; absent from older answers. */ + version: Schema.optional(NonNegativeInt), + author: Actor, + body: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + path: Schema.NullOr(Schema.String), + // The API allows any integer here; only a positive line anchors a review thread. + line: Schema.NullOr(Schema.Int), + side: Schema.NullOr(Schema.Literals(["left", "right"])), + commitOid: Schema.NullOr(Schema.String), + resolvedAt: Schema.NullOr(IsoDateTime), + capabilities: Schema.optional(CommentCapabilities), +}); +type GitCafeComment = typeof GitCafeComment.Type; + +export const GitCafeComments = Schema.Struct({ + items: Schema.Array(GitCafeComment), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeReviews = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + author: Actor, + body: Schema.NullOr(Schema.String), + verdict: Schema.Literals(["approve", "request_changes", "comment"]), + dismissedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeCommits = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), + ), + truncated: Schema.Boolean, + next: Schema.NullOr(Schema.String), + headOid: Schema.String, +}); + +export const GitCafeReactions = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + subject: Schema.Struct({ + kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), + id: TrimmedNonEmptyString, + }), + emoji: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), + ]), + count: PositiveInt, + viewerReactionId: Schema.NullOr(Schema.String), + reactors: Schema.Array(Actor), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +const GitCafeStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + /** What a land or restack fences on. */ + revision: PositiveInt, + landingBase: Schema.String, + members: Schema.Array( + Schema.Struct({ + pullRequestNumber: PositiveInt, + title: Schema.String, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + headOid: Schema.optional(Schema.String), + position: PositiveInt, + }), + ), +}); +export type GitCafeStack = typeof GitCafeStack.Type; + +export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); + +const GitCafeDiffFile = Schema.Struct({ + path: TrimmedNonEmptyString, + oldPath: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional( + Schema.Literals([ + "added", + "deleted", + "modified", + "typeChanged", + "conflicted", + "renamed", + "copied", + ]), + ), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + binary: Schema.optional(Schema.Boolean), + tooLarge: Schema.optional(Schema.Boolean), + hunksOmitted: Schema.optional(Schema.Boolean), + isSubmodule: Schema.optional(Schema.Boolean), + oldOid: Schema.optional(Schema.String), + newOid: Schema.optional(Schema.String), + // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + hunks: Schema.optional( + Schema.Array( + Schema.Struct({ + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, + lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), + }), + ), + ), +}); +export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; +export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); + +export const GitCafeDiff = Schema.Struct({ + items: GitCafeDiffFiles, + truncated: Schema.optional(Schema.Boolean), +}); + +export const GitCafeChecks = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + status: Schema.Literals(["queued", "in_progress", "completed"]), + conclusion: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + detailsUrl: Schema.NullOr(Schema.String), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +interface RepositoryOnHost { + readonly repository: string; + readonly host: string; +} + +function toActor(actor: Actor, host: string): PullRequestActor | null { + if (actor.kind === "unavailable") return null; + return { + login: actor.kind === "local" ? actor.handle : actor.login, + name: actor.kind === "local" ? actor.displayName : null, + // GitCafe serves its own avatars by path, so a relative one is read against the host. + avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, + }; +} + +/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ +export function toReviewers( + pull: Pick, + host: string, +): ReadonlyArray { + return (pull.reviewers ?? []).flatMap(({ actor }) => { + const reviewer = toActor(actor, host); + return reviewer === null ? [] : [reviewer]; + }); +} + +/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ +function reviewerLogins(actor: Actor, host: string): ReadonlyArray { + const reviewer = toActor(actor, host); + if (reviewer === null) return []; + return actor.kind === "github" && actor.linkedProfile + ? [reviewer.login, actor.linkedProfile.handle] + : [reviewer.login]; +} + +export function toChangeRequest( + pull: GitCafePull, + target: RepositoryOnHost, +): ProviderChangeRequest { + return { + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + author: toActor(pull.author, target.host), + headBranch: pull.sourceBranch, + baseBranch: pull.targetBranch, + state: pull.state, + isDraft: pull.draft, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt, + reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => + reviewerLogins(actor, target.host), + ), + labels: pull.labels ?? [], + }; +} + +/** What GitCafe's own capabilities let this viewer do with the pull request. */ +export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { + const { comment, edit, review, merge, moderate } = detail.capabilities; + // A locked conversation keeps its composer for moderators only. + const open = comment && (detail.lockedAt == null || moderate); + return { + actions: [ + // A merged pull request is settled; a closed one can only be reopened. + ...(edit && detail.state === "open" + ? ([detail.draft ? "ready" : "draft", "close"] as const) + : edit && detail.state === "closed" + ? (["reopen"] as const) + : []), + ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), + ], + stackRebase: merge, + comment: open, + // Each thread's root narrows this further with its own `canResolve`. + resolve: open, + verdicts: review ? ["approve", "request-changes", "comment"] : [], + requestReviewers: false, + editChangeRequest: edit, + // GitCafe's only branch update moves a whole stack. + updateMethods: [], + }; +} + +const REACTIONS: Readonly> = { + "👍": "thumbs-up", + "👎": "thumbs-down", + "😄": "laugh", + "🎉": "hooray", + "😕": "confused", + "❤": "heart", + "🚀": "rocket", + "👀": "eyes", +}; + +/** The emoji GitCafe takes for one of the shared reactions. */ +export const REACTION_EMOJI: Readonly> = { + "thumbs-up": "👍", + "thumbs-down": "👎", + laugh: "😄", + hooray: "🎉", + confused: "😕", + heart: "❤️", + rocket: "🚀", + eyes: "👀", +}; + +/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ +export function reactionContent(emoji: string): PullRequestReactionContent | undefined { + return REACTIONS[emoji.replaceAll("\uFE0F", "")]; +} + +function toReactions(data: typeof GitCafeReactions.Type, host: string) { + const reactions: Array = []; + const byComment = new Map>(); + for (const item of data.items) { + const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; + if (content === undefined) continue; + const reaction: PullRequestReaction = { + content, + count: item.count, + actors: item.reactors.flatMap((actor) => { + const reactor = toActor(actor, host); + return reactor === null ? [] : [reactor.login]; + }), + viewerHasReacted: item.viewerReactionId !== null, + }; + if (item.subject.kind === "pull_request") reactions.push(reaction); + else if (item.subject.kind === "pull_request_comment") + byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); + } + return { reactions, byComment }; +} + +const REVIEW_STATES = { + approve: "approved", + request_changes: "changes_requested", + comment: "commented", +} as const; + +/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ +export function toActivity(input: { + readonly comments: typeof GitCafeComments.Type; + readonly reviews: typeof GitCafeReviews.Type; + readonly commits: typeof GitCafeCommits.Type; + readonly reactions?: typeof GitCafeReactions.Type; + readonly headOid?: string; + readonly host: string; +}): ProviderChangeRequestActivity { + const { host } = input; + const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); + const remark = (comment: GitCafeComment) => ({ + id: comment.id, + author: toActor(comment.author, host), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: null, + ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), + ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), + }); + // Stable, so remarks written in the same instant keep GitCafe's own order. + const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const groups = new Map>(); + for (const comment of ordered) { + if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) + continue; + groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); + } + const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { + const first = group[0]!; + const latest = group.at(-1)!; + const root = group.find((comment) => comment.id === comment.threadId); + return { + id: first.threadId, + path: first.path!, + line: first.line, + side: first.side!, + isResolved: root?.resolvedAt != null, + isOutdated: + input.headOid !== undefined && + latest.commitOid !== null && + latest.commitOid !== input.headOid, + comments: group.map(remark), + ...(root?.capabilities === undefined + ? {} + : { + canResolve: + root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, + }), + }; + }); + const comments: Array = [ + ...ordered.map((comment) => ({ + ...remark(comment), + kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), + path: comment.path, + reviewState: null, + })), + ...input.reviews.items.map((review) => ({ + id: review.id, + kind: "review" as const, + author: toActor(review.author, host), + body: review.body ?? "", + createdAt: review.createdAt, + url: null, + path: null, + reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], + })), + ]; + return { + comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), + commentCount: input.comments.items.length + input.reviews.items.length, + commentsTruncated: input.comments.next !== null || input.reviews.next !== null, + reviewThreads, + commits: input.commits.items.map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.summary, + committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), + })), + ...(reactions === undefined ? {} : { reactions: reactions.reactions }), + }; +} + +export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { + return { + id: stack.id, + number: stack.number, + url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, + base: stack.landingBase, + layers: stack.members + .toSorted((a, b) => a.position - b.position) + .map((member) => ({ + number: member.pullRequestNumber, + title: member.title, + headBranch: member.sourceBranch, + state: member.state, + isDraft: member.draft, + ...(member.headOid === undefined ? {} : { headSha: member.headOid }), + })), + }; +} + +/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ +export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { + const chunks: Array = []; + const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; + for (const file of diff.items) { + const oldPath = file.oldPath ?? file.path; + const a = quoteGitPatchPath(`a/${oldPath}`); + const b = quoteGitPatchPath(`b/${file.path}`); + const before = file.status === "added" ? "/dev/null" : a; + const after = file.status === "deleted" ? "/dev/null" : b; + const mode = file.isSubmodule ? "160000" : "100644"; + const lines = [`diff --git ${a} ${b}`]; + if (file.status === "added") lines.push(`new file mode ${mode}`); + if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); + if (file.status === "renamed" || file.status === "copied") { + const action = file.status === "renamed" ? "rename" : "copy"; + lines.push( + `${action} from ${quoteGitPatchPath(oldPath)}`, + `${action} to ${quoteGitPatchPath(file.path)}`, + ); + } + if (file.binary) { + lines.push(`Binary files ${before} and ${after} differ`); + } else if (file.tooLarge || file.hunksOmitted) { + omittedFileStats.push({ + path: file.path, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + }); + } else if (file.isSubmodule) { + const hasOld = file.status !== "added" && file.oldOid !== undefined; + const hasNew = file.status !== "deleted" && file.newOid !== undefined; + lines.push(`--- ${before}`, `+++ ${after}`); + lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); + if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); + if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); + } else { + const hunks = file.hunks ?? []; + if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); + for (const hunk of hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + // GitCafe marks a missing final newline with libgit2's own origins. + if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); + else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + } + } + } + chunks.push(lines.join("\n")); + } + return { + patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, + truncated: diff.truncated === true || omittedFileStats.length > 0, + nextCursor: null, + ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), + }; +} + +function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { + if (check.status !== "completed") return "pending"; + switch (check.conclusion) { + case "success": + return "success"; + case "neutral": + return "neutral"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "action_required": + return "action-required"; + default: + return "failure"; + } +} + +export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + return checks.items.map((check) => ({ + name: check.name, + status: checkStatus(check), + description: check.summary, + url: check.detailsUrl, + })); +} diff --git a/packages/source-control-gitcafe/tsconfig.json b/packages/source-control-gitcafe/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/source-control-gitcafe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31..249f45a453cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../../packages/source-control-gitcafe '@t3tools/source-control-github': specifier: workspace:* version: link:../../packages/source-control-github @@ -1643,6 +1646,37 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitcafe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-github: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7bc6da481716..272026efd71e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", From 500cb4266f2bb290aba2c2038295500ff7b25188 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:21:14 -0700 Subject: [PATCH 175/377] fix(web): add provider wizard no longer shifts sideways while it grows (#17292) --- apps/web/src/components/ui/wizard.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 695d4b047ff9..be2b10ab761c 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -4,6 +4,7 @@ import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; +import { ScrollArea } from "./scroll-area"; /** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ export function WizardPopup({ @@ -14,14 +15,10 @@ export function WizardPopup({ readonly size?: "default" | "wide"; }) { return ( - -

+ + +
{children}
+
); } From 42f71dfe0768ee768283d50140e7d360a635ee28 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:23:15 -0700 Subject: [PATCH 176/377] fix(web): PR search keeps the caret where you type (#17675) --- .../components/pullRequest/PullRequestListFilters.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cbb0e01b9da3..1fb90f0b3052 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -104,6 +104,7 @@ export function PullRequestSearchInput({ busy?: boolean; onChange: (value: string) => void; }) { + const [draft, setDraft] = useState(null); return ( @@ -111,8 +112,13 @@ export function PullRequestSearchInput({ onChange(event.currentTarget.value)} + value={draft ?? value} + onFocus={() => setDraft(value)} + onBlur={() => setDraft(null)} + onChange={(event) => { + setDraft(event.currentTarget.value); + onChange(event.currentTarget.value); + }} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" /> From 6d5ea190a4a54b01dfe6500e032526839e3258d1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:24:11 -0700 Subject: [PATCH 177/377] fix(server): thread PR badges catch up when another environment reads the PR (#17729) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../pullRequest/PullRequestService.test.ts | 42 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 41 ++++++++++++++++++ apps/server/src/ws.ts | 1 + .../src/state/pullRequestRouting.ts | 22 ++++++++++ .../src/state/pullRequests.test.ts | 11 +++++ packages/contracts/src/pullRequest.ts | 11 +++++ packages/contracts/src/rpc.ts | 9 ++++ 9 files changed, 139 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..11756026f6d1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -135,6 +135,7 @@ export const RPC_REQUIRED_SCOPES = { // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope, // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..26a4b007b8b6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -118,6 +118,7 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsSetReaction]: "pull-requests", [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsReportState]: "pull-requests", [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c74faa92f06d..117fd3f69a32 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4972,6 +4972,48 @@ it.effect("announces state a detail read sees first or newly", () => }), ); +it.effect("announces a reported state without trusting it", () => + Effect.gen(function* () { + let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), + }), + ], + }); + const announced: Array = []; + yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => + Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), + ).pipe(Effect.forkChild({ startImmediately: true })); + const note = (state: "open" | "closed" | "merged") => + service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); + const readDetail = Effect.gen(function* () { + yield* service.invalidate({ reference }); + yield* service.detail(reference); + yield* Effect.yieldNow; + }); + + yield* readDetail; + yield* note("closed"); + yield* note("closed"); + assert.strictEqual(announced.length, 2); + yield* TestClock.adjust("5 minutes"); + yield* note("closed"); + assert.strictEqual(announced.length, 3); + + yield* note("open"); + assert.strictEqual(announced.length, 4); + + yield* note("merged"); + detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; + yield* readDetail; + assert.strictEqual(announced.length, 6); + }), +); + it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b44d713c3158..51377f1b33b1 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -42,6 +42,7 @@ import { type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, + type PullRequestReportStateInput, type PullRequestListEntry, type PullRequestListFilters, type PullRequestListInput, @@ -160,6 +161,8 @@ const detailTimeToLive = (state: PullRequestState | undefined) => * page and a watched pull request's change reaches its watch. */ const CHECKS_CACHE_TTL = Duration.seconds(15); +/** How long a repeated reported state is skipped, so a wrong one cannot hide a real change for long. */ +const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000; const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); @@ -244,6 +247,13 @@ export class PullRequestService extends Context.Service< never, Scope.Scope >; + /** + * The state a read routed to another environment saw. A new one reaches + * `subscribeStateChanges`, so the host is asked again; the report itself is never trusted. + */ + readonly reportState: ( + input: PullRequestReportStateInput, + ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; @@ -3115,6 +3125,36 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); + // The last state each routed read reported, kept apart from `detailStates`: an unverified report + // must never outrank a reading this environment made itself. + const reportedStates = new Map< + string, + { readonly state: PullRequestState; readonly atMs: number } + >(); + const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( + Effect.flatMap(([ref, nowMs]) => + Effect.suspend(() => { + const scope = refScope(ref); + const reported = reportedStates.get(scope); + if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + return Effect.void; + reportedStates.delete(scope); + if (reportedStates.size >= REF_EPOCH_CAPACITY) { + const oldest = reportedStates.keys().next().value; + if (oldest !== undefined) reportedStates.delete(oldest); + } + reportedStates.set(scope, { state, atMs: nowMs }); + return ref.host === undefined + ? Effect.void + : PubSub.publish(stateChanges, { + host: ref.host, + repository: ref.repository, + number: ref.number, + }); + }), + ), + ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); // Record the summary from a host or cache read, not the stale value @@ -3432,6 +3472,7 @@ export const make = Effect.gen(function* () { subscribeStateChanges: PubSub.subscribe(stateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), + reportState, subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( Stream.filter((revision) => revision > 0), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b..c1a01b963a50 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2518,6 +2518,7 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => withPullRequestViewer(input, pullRequests.setReaction(input)), + [WS_METHODS.pullRequestsReportState]: (input) => pullRequests.reportState(input), [WS_METHODS.pullRequestsInvalidate]: (input) => pullRequests.invalidate(input, { notifyReaders: true }).pipe( // A reader asking for fresh host state also wants the thread badges it feeds to diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index f8781837c361..2233dd8a6722 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -2,6 +2,7 @@ import { WS_METHODS, PullRequestRef, PullRequestInvalidateInput, + PullRequestState, type EnvironmentId, type PullRequestRoutingResult, type PullRequestRoutingIdentityResult, @@ -57,6 +58,7 @@ const writes = new Set([ WS_METHODS.pullRequestsSetLabels, ]); const isRef = Schema.is(PullRequestRef); +const hasState = Schema.is(Schema.Struct({ state: PullRequestState })); const isInvalidation = Schema.is(PullRequestInvalidateInput); const readTimeout = (environmentId: EnvironmentId) => Effect.timeoutOrElse({ @@ -208,6 +210,21 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi ); }); +/** + * The origin owns the threads linked to this pull request, but a routed read never reaches it. + * Tell it which state the read saw so it checks again. Best effort, and never holds up the read. + */ +const reportState = ( + { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, + state: PullRequestState, +) => + request(WS_METHODS.pullRequestsReportState, { reference, state }).pipe( + Effect.timeoutOption("5 seconds"), + Effect.ignore, + Effect.forkDetach, + Effect.asVoid, + ); + /** Credentials stay on their environments. Only a verified host and account cross the wire. */ export function createPullRequestRouter() { const routedRequest = Effect.fn("PullRequestRouting.request")(function* < @@ -356,6 +373,11 @@ export function createPullRequestRouter() { ? guardedSource : registry.run(id, request(tag, routedInput)) ).pipe( + Effect.tap((result) => + id !== origin.target.environmentId && hasState(result) + ? reportState(ref, result.state) + : Effect.void, + ), Effect.tap(() => Effect.sync(() => { const entry = used.get(refKey) ?? { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 8762e9edb399..75b76d8c3f4e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -995,6 +995,7 @@ it.effect.each( it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( Effect.gen(function* () { + const noted = yield* Deferred.make(); const clientFor = (local: boolean) => ({ [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => @@ -1013,8 +1014,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "local project", workspaceRoot: "/Users/local/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", }) : Effect.never, + [WS_METHODS.pullRequestsReportState]: (input: unknown) => + Deferred.succeed(noted, input).pipe(Effect.asVoid), }) as unknown as WsRpcProtocolClient; const { environmentRegistry, supervisor } = yield* makeTestRuntime( clientFor(false), @@ -1035,6 +1040,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "source project", workspaceRoot: "/srv/source/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", + }); + expect(yield* Deferred.await(noted)).toEqual({ + reference: { projectId: "project-1", repository: "acme/web", number: 7 }, + state: "closed", }); }), ), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1efbc1808d14..82e5dc1cd15f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -849,6 +849,17 @@ export const PullRequestInvalidateInput = Schema.Struct({ }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; +/** + * The state a read routed to another environment saw, reported to the environment the read was + * for so its thread links can catch up. A hint only: that environment confirms with the host + * before writing anything. + */ +export const PullRequestReportStateInput = Schema.Struct({ + reference: PullRequestRef, + state: PullRequestState, +}); +export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; + export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..289b7041be16 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -171,6 +171,7 @@ import { PullRequestDiffFileContentsResult, PullRequestFilesViewedResult, PullRequestInvalidateInput, + PullRequestReportStateInput, PullRequestListInput, PullRequestListResult, PullRequestListStatsInput, @@ -543,6 +544,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsReportState: "pullRequests.reportState", pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", @@ -1087,6 +1089,12 @@ const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, error: PullRequestRpcError, }); +const WsPullRequestsReportStateRpc = Rpc.make(WS_METHODS.pullRequestsReportState, { + payload: PullRequestReportStateInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { payload: Schema.Struct({}), success: NonNegativeInt, @@ -1916,6 +1924,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsReportStateRpc, WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, From bd2346eda2e2c380d1844869c7fd16c279d2190f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:36:04 -0700 Subject: [PATCH 178/377] fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 89 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 13 +++ packages/contracts/src/editor.ts | 13 +++ 3 files changed, 115 insertions(+) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6df4ea16db88..d16391a3ae70 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +const launchWindowsShimEditor = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + let spawned: ChildProcess.StandardCommand | undefined; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + return { exit, spawned }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect.each([ + ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"], + ["LF", "C:\\workspace\\file.ts\ncalc.exe"], + ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'], +] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor(cwd); + assert.equal(spawned, undefined); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherUnsupportedTargetError, + ); + } + }), +); + +it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor( + "C:\\Users\\jö\\R&D (100%)\\naïve ^file!.ts:3:7", + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.ok(spawned); + assert.equal(spawned.options.shell, true); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"', + ]); + }), +); + +it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const zed = path.join(binDir, "zed"); + yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); + yield* fileSystem.chmod(zed, 0o755); + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir, HOME: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 74e95b9ddcc4..8ab130b01e38 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -14,6 +14,7 @@ import { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, @@ -53,6 +54,7 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { @@ -74,6 +76,14 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +/** + * Windows command shims (`code.cmd`) forward arguments through `%*`, which + * cmd.exe parses a second time after the first escaping layer is gone: a + * line break ends the command there, and a double quote closes the quoting + * that keeps `&` or `|` literal. Neither can occur in a Windows path. + */ +// oxlint-disable-next-line no-control-regex +const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu } const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); + if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) { + return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor }); + } yield* launchAndUnref( { command: spawnCommand.command, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75dca95786bf..7fa324a425ad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( + "ExternalLauncherUnsupportedTargetError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `${this.editor} cannot open a path containing line breaks or double quotes`; + } +} + const ExternalLauncherSpawnFields = { command: Schema.String, args: Schema.Array(Schema.String), @@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, ExternalLauncherCommandNotFoundError, + ExternalLauncherUnsupportedTargetError, ExternalLauncherBrowserSpawnError, ExternalLauncherEditorSpawnError, ]); From c77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 03:34:14 -0500 Subject: [PATCH 179/377] fix(web): align compact button touch targets (#17748) --- .../BranchToolbarBranchSelector.tsx | 6 ++- .../src/components/ComposerContextLabel.tsx | 4 +- apps/web/src/components/GitActionsControl.tsx | 41 +++++++++++-------- .../src/components/ProjectScriptsControl.tsx | 9 ++-- .../chat/ComposerPrimaryActions.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 3 +- .../components/chat/ThreadDetailsPrRow.tsx | 5 ++- .../chat/threadDetailsPanelStyles.ts | 5 +++ .../web/src/components/ui/middle-truncate.tsx | 10 ++++- 9 files changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1d4185b9092f..7af46f2d47a2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -779,7 +779,11 @@ export function BranchToolbarBranchSelector({ )} /> - + {displayMode !== "panel" ? ( diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 1abecfdf8ca8..8efcba43a75c 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "../lib/utils"; +import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; /** Keeps text measurable while the composer's outer label box collapses. */ export function ComposerContextLabel({ @@ -16,7 +17,7 @@ export function ComposerContextLabel({ className={cn( "min-w-0", displayMode === "panel" - ? "flex-1 truncate text-left" + ? "flex-1 overflow-x-clip overflow-y-visible text-left" : "max-w-[240px] group-data-[compact]/composer-context:max-w-0", )} > @@ -24,6 +25,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", + displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2b684cd0cd4e..2063767f315c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -127,6 +127,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; @@ -441,23 +442,25 @@ function GitActionProgressButtonContent({ aria-live="polite" className={cn( "grid min-w-0 flex-1 items-center", - // Pin the title row to the button's minimum content height (min-height - // minus vertical padding and border) so revealing the output row - // extends the button downward without re-centering — the title must - // not shift. No row gap: the collapsed output row must contribute zero - // height so the single-line running button matches the static button - // exactly. The panel column gap matches the static row's icon-to-label - // distance (gap-2.5 plus the label's ml-0.5). In the panel the elapsed - // counter renders outside the button (in the menu-chevron slot), so - // there is no trailing column. + // Pin the title row to the button's minimum content height so output + // expands below it. Panel controls leave 24px after padding and border. + // No row gap: collapsed output must not add height. The panel's elapsed + // counter uses the menu-chevron slot, so it has no trailing column. isPanel - ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] gap-x-3" + ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] gap-x-3" : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]", )} role="status" >