From 4daec109cdf8e3e92b392017d7564e735e54f6fd Mon Sep 17 00:00:00 2001 From: chise Date: Fri, 9 Oct 2026 01:04:26 +0700 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 23/27] 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 24/27] 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 25/27] 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 26/27] 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 1374d5a54ba1a2d91ba1f5384169287d7bbda674 Mon Sep 17 00:00:00 2001 From: "chamaquita-d3[bot]" <333130990+chamaquita-d3[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:02:33 +0000 Subject: [PATCH 27/27] fix: align usage panel and desktop tests with merged upstream UsageSidebarPanel reads provider labels through providerClients, since upstream removed getDriverOption. The new upstream desktop tests expect the fork's D3 Code nightly branding. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/src/app/DesktopAppIdentity.test.ts | 9 +++++++-- apps/desktop/src/window/DesktopApplicationMenu.test.ts | 8 ++++---- .../components/usage/UsageSidebarPanel.refresh.test.tsx | 4 +++- apps/web/src/components/usage/UsageSidebarPanel.tsx | 4 ++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index cc0277d3ed10..80b5a915ccd8 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -261,10 +261,15 @@ describe("DesktopAppIdentity", () => { const runtimeName = calls.setName[0]; assert.isDefined(runtimeName); - assert.equal(runtimeName, `T3 Code ${stage}`); + // This fork brands its nightly builds as "D3 Code". + const baseName = stage === "Nightly" ? "D3 Code" : "T3 Code"; + assert.equal(runtimeName, `${baseName} ${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})`); + assert.equal( + calls.setAboutPanelOptions[0]?.applicationName, + stage === "Nightly" ? "D3 Code" : `T3 Code (${stage})`, + ); }), { calls, environment }, ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 8de28324a58d..4ca7adf3b4d7 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -140,21 +140,21 @@ describe("DesktopApplicationMenu", () => { const template = yield* Deferred.await(applicationMenuTemplate); const applicationMenu = template[0]; assert.isDefined(applicationMenu); - assert.equal(applicationMenu.label, "T3 Code (Nightly)"); + assert.equal(applicationMenu.label, "D3 Code"); 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)", + "About D3 Code", ); assert.equal( applicationMenu.submenu.find((item) => item.role === "hide")?.label, - "Hide T3 Code (Nightly)", + "Hide D3 Code", ); assert.equal( applicationMenu.submenu.find((item) => item.role === "quit")?.label, - "Quit T3 Code (Nightly)", + "Quit D3 Code", ); }), ); diff --git a/apps/web/src/components/usage/UsageSidebarPanel.refresh.test.tsx b/apps/web/src/components/usage/UsageSidebarPanel.refresh.test.tsx index 0da008203862..c9573e136a95 100644 --- a/apps/web/src/components/usage/UsageSidebarPanel.refresh.test.tsx +++ b/apps/web/src/components/usage/UsageSidebarPanel.refresh.test.tsx @@ -24,7 +24,9 @@ vi.mock("../../state/usage", () => ({ vi.mock("../ui/sidebar", () => ({ SidebarContent: "div", SidebarGroup: "div" })); 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" }) }, +})); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); vi.mock("../sidebar/SidebarChrome", () => ({ SidebarChromeFooter: () => null })); diff --git a/apps/web/src/components/usage/UsageSidebarPanel.tsx b/apps/web/src/components/usage/UsageSidebarPanel.tsx index 9ddf6099d26b..6999191c0bd2 100644 --- a/apps/web/src/components/usage/UsageSidebarPanel.tsx +++ b/apps/web/src/components/usage/UsageSidebarPanel.tsx @@ -34,7 +34,7 @@ import { serverEnvironment } from "../../state/server"; import { useUsage } from "../../state/usage"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { SidebarChromeFooter } from "../sidebar/SidebarChrome"; import { SidebarHeaderIconButton } from "../sidebar/SidebarThreadHeader"; import { RefreshIcon } from "../ui/refresh-icon"; @@ -71,7 +71,7 @@ interface ProviderCard { } function poolLabel(pool: LimitPool): string { - return getDriverOption(pool.driver)?.label ?? String(pool.driver); + return providerClients.get(pool.driver)?.label ?? String(pool.driver); } /** The plan every account in the pool is on, or null when they disagree. */